commit db2b505ca37159d3f2324b9602849f5513b7fcfc Author: Hermes Date: Thu Nov 12 23:41:13 2026 +0000 [verified] Publish ReelBox source under GPLv3 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4b9595f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Keep upstream code, license texts and raw evidence byte-for-byte. +# Whitespace checking remains enabled for first-party source and website code. +third_party/background_downloader/** -whitespace +third_party/licenses/** -whitespace +docs/verification-logs-0.2.0/** -whitespace +docs/verification-publication/** -whitespace +docs/verification-logs-0.4.2/** -whitespace +LICENSE -whitespace +android/gradlew.bat whitespace=cr-at-eol diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a13f1d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# 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-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# 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 + +# Widget Preview related +.widget_preview/ + +# Never ship signing credentials or local tool locks +*.jks +*.keystore +*.p12 +**/keystore.properties +.tooling.lock + +# Private configuration and generated files +.env +.env.* +*.pem +*.key +*.pfx +*.db +*.sqlite +*.sqlite3 +*.apk +*.aab +local.properties +.gradle/ +.kotlin/ +__pycache__/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6e45c8a --- /dev/null +++ b/.metadata @@ -0,0 +1,33 @@ +# 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: "c197ca5199e9ee5661e865596c9c07294dfab51c" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + - platform: android + create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + - platform: web + create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + + # 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/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff87176 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# ReelBox + +ReelBox is an open-source Android app for watching films and series, released under the [GNU GPLv3](LICENSE). No account or subscription is required. + +## Domestic network + +ReelBox is designed for use on Iran's domestic network (ملانت), with its catalogue hosted inside Iran. Streaming and downloads still depend on the availability of each media host and your connection. + +## Features + +- Browse and search films and series, with genre, country and sorting filters +- Watch with the built-in player, choose available qualities, use subtitles and resume playback +- Download for offline viewing and manage downloads inside the app +- Keep favourites, collections, watched titles and viewing history on your device +- Export and restore your library +- Open media in an external player or downloader, with experimental Cast support +- English and Persian interfaces + +## Beta + +ReelBox is in beta. Many features are incomplete, experimental or buggy and will be improved in future updates. + +## Catalogue + +The current catalogue contains **30,009 titles: 20,474 films and 9,535 series**, based on the November 11, 2026 snapshot after excluding promotional and non-content entries. Availability can change, and not every title is guaranteed to play. + +## Sponsor + +This project was sponsored by [Buffalo VPN](https://t.me/vpnbufalo). diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..232ad7d --- /dev/null +++ b/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,25 @@ +# Third-party notices + +The top-level GPL-3.0-only license applies to Reelbox's original application and supporting code. Existing third-party notices are preserved; the top-level license does not replace them or grant rights in external media. + +## Dart and Flutter + +`pubspec.lock` identifies the resolved versions. `third_party/DEPENDENCIES.json` maps every resolved Dart/Flutter package to its upstream and retained license text in `third_party/licenses/dart/`. This includes build/test-only packages as well as runtime packages. Flutter and Dart SDKs are obtained separately, not redistributed in this repository. + +The local `third_party/background_downloader/` tree is version 9.6.5 under its retained BSD-3-Clause license. The two Dart-only multipart resume fixes are described in `NOTICE.txt`; original copyright/license notices remain in place. + +## Native media and Android libraries + +The MIT-licensed `media_kit_libs_android_video` 1.3.8 Flutter plugin obtains its native ARM64 library from the **v1.1.7** build at https://github.com/media-kit/libmpv-android-video-build. Its exact download URL and checksum are specified in that package's `android/build.gradle`. + +The plugin's MIT license must **not** be mistaken for the licenses of bundled mpv, FFmpeg and their codec/subtitle libraries. Their upstream source/build recipes and component license notices remain applicable. Native build recipes: https://github.com/media-kit/libmpv-android-video-build/tree/v1.1.7. mpv source: https://github.com/mpv-player/mpv. FFmpeg source: https://ffmpeg.org/download.html. Consult those exact component revisions/build recipes before redistributing a modified native stack. + +Android platform/support libraries and Google Play services Cast Framework are separately resolved by Gradle. Direct native dependencies are pinned in `android/app/build.gradle.kts`; Gradle/Android SDK components are not relicensed by the project's GPL grant. See their distributed notices and applicable upstream terms. This inventory is not a certification of every possible dependency combination or app-store eligibility. + +The Material Icons font is a Flutter-provided asset under its own Apache-2.0 notice, retained in `third_party/licenses/material-icons.txt`. + +## Website + +The screenshot is a host-rendered capture of Reelbox's actual interface, not evidence of Android hardware behavior. It contains no third-party film artwork. + +Design process: **Unslop by Damir Sokolovsky (@heydamir)**, https://heydamir.design/unslop, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). The website is an original implementation using that process, not a copy of the upstream reference layouts. Changes include a copy-only cleanup retaining Reelbox’s existing cinema direction. Attribution is retained here, in HTML/CSS source comments and retained source comments, rather than visible promotional copy. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..3983de0 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,34 @@ +# 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 + +analyzer: + exclude: + - build/** + - android/** + - web/** + +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/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/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/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..97544f9 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,94 @@ +import java.util.Properties + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val signingFile = file(System.getenv("REELBOX_SIGNING_PROPERTIES") + ?: "${System.getProperty("user.home")}/.config/reelbox-signing/keystore.properties") +val signingProperties = Properties().apply { + if (signingFile.isFile) signingFile.inputStream().use { load(it) } +} + +android { + signingConfigs { + create("release") { + if (signingFile.isFile) { + storeFile = file(signingProperties.getProperty("storeFile")) + storePassword = signingProperties.getProperty("storePassword") + keyAlias = signingProperties.getProperty("keyAlias") + keyPassword = signingProperties.getProperty("keyPassword") + } + } + } + namespace = "org.reelbox.reelbox" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + // Dependency release-lint analysis exceeded this host's memory/time budget. + // Run Android lint separately on a larger machine before store distribution. + lint { + checkReleaseBuilds = false + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "org.reelbox.app" + // This delivery targets ARM64. Exclude dependency-only ABIs that lack + // Flutter's libapp/libflutter and would falsely advertise support. + ndk { + abiFilters.clear() + abiFilters.add("arm64-v8a") + } + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = maxOf(flutter.minSdkVersion, 24) // Native default-network callback / scoped media lifecycle. + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // Missing release keys fail closed; never use Android's debug signer. + signingConfig = signingConfigs.getByName("release") + // Keep Dart AOT release mode; avoid the Java R8/resource shrinker + // on the constrained build host. This increases APK size. + isMinifyEnabled = false + isShrinkResources = false + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // Maven Central metadata verified 2026-11-04: latest stable 3.7.7; + // 4.0.0-eap29 is a prerelease. API checked against published sources/classes.jar. + implementation("org.videolan.android:libvlc-all:3.7.7") + // Google currently documents 23.0.0, but its MediaRouter 1.9 beta requires + // compileSdk 37. Use verified 22.3.1 on this repository's installed SDK 35. + implementation("com.google.android.gms:play-services-cast-framework:22.3.1") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.core:core:1.16.0") + implementation("androidx.mediarouter:mediarouter:1.8.1") +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..134f9a4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/BackgroundPlaybackService.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/BackgroundPlaybackService.kt new file mode 100644 index 0000000..7d6253e --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/BackgroundPlaybackService.kt @@ -0,0 +1,209 @@ +package org.reelbox.reelbox + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import io.flutter.plugin.common.MethodChannel + +/** The existing Flutter engine/Player owns decoding and is the sole event consumer. */ +object NativeMediaEvents { + var channel: MethodChannel? = null + fun action(action: String, positionMs: Long? = null) { + val arguments = mutableMapOf("action" to action) + if (positionMs != null) arguments["positionMs"] = positionMs + Handler(Looper.getMainLooper()).post { channel?.invokeMethod("mediaAction", arguments) } + } +} + +/** Foreground lifecycle/session/notification bridge. Deliberately contains NO decoder. */ +class BackgroundPlaybackService : Service() { + companion object { + private const val CHANNEL = "reelbox_playback" + private const val NOTIFICATION_ID = 3041 + private const val UPDATE = "org.reelbox.app.UPDATE_PLAYBACK" + private const val CONTROL = "org.reelbox.app.MEDIA_CONTROL" + private var running: BackgroundPlaybackService? = null + + fun start(context: Context, args: Map<*, *>) { + require(NativeMediaEvents.channel != null) { "No active Flutter media owner." } + val intent = Intent(context, BackgroundPlaybackService::class.java).setAction(UPDATE) + val snapshot = PlaybackSnapshot.from(args) + intent.putExtra("title", snapshot.title).putExtra("playing", snapshot.playing) + .putExtra("positionMs", snapshot.positionMs).putExtra("durationMs", snapshot.durationMs) + if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) + } + fun update(args: Map<*, *>) { running?.applySnapshot(PlaybackSnapshot.from(args, running!!.snapshot)) } + fun stop(context: Context) { context.stopService(Intent(context, BackgroundPlaybackService::class.java)) } + } + + private lateinit var session: MediaSession + private lateinit var audio: AudioManager + private var snapshot = PlaybackSnapshot() + private var focusRequest: AudioFocusRequest? = null + private var focusHeld = false + private var interrupted = false + private var resumeOnFocus = false + private var noisyRegistered = false + private val focusListener = AudioManager.OnAudioFocusChangeListener { change -> + when (change) { + AudioManager.AUDIOFOCUS_GAIN -> { + interrupted = false + if (resumeOnFocus) NativeMediaEvents.action("play") + resumeOnFocus = false + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + resumeOnFocus = snapshot.playing + interrupted = true + NativeMediaEvents.action("pause") + } + AudioManager.AUDIOFOCUS_LOSS -> { + interrupted = false + resumeOnFocus = false + abandonFocus() + NativeMediaEvents.action("pause") + } + } + } + private val noisy = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) NativeMediaEvents.action("pause") + } + } + + override fun onCreate() { + super.onCreate() + running = this + audio = getSystemService(AUDIO_SERVICE) as AudioManager + if (Build.VERSION.SDK_INT >= 26) { + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel( + NotificationChannel(CHANNEL, "Background playback", NotificationManager.IMPORTANCE_LOW) + ) + } + session = MediaSession(this, "ReelboxPlayback").apply { + setCallback(object : MediaSession.Callback() { + override fun onPlay() = NativeMediaEvents.action("play") + override fun onPause() = NativeMediaEvents.action("pause") + override fun onStop() { NativeMediaEvents.action("stop"); stopSelf() } + override fun onSeekTo(pos: Long) = NativeMediaEvents.action("seek", pos.coerceAtLeast(0)) + override fun onSkipToNext() = NativeMediaEvents.action("next") + override fun onSkipToPrevious() = NativeMediaEvents.action("previous") + }, Handler(Looper.getMainLooper())) + isActive = true + } + val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + if (Build.VERSION.SDK_INT >= 33) registerReceiver(noisy, filter, Context.RECEIVER_NOT_EXPORTED) + else @Suppress("DEPRECATION") registerReceiver(noisy, filter) + noisyRegistered = true + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (NativeMediaEvents.channel == null || intent == null) { stopSelf(); return START_NOT_STICKY } + if (intent.action == CONTROL) { + val action = intent.getStringExtra("action") + if (action in listOf("play", "pause", "stop", "next", "previous")) NativeMediaEvents.action(action!!) + if (action == "stop") stopSelf() + } else if (intent.action == UPDATE) { + snapshot = PlaybackSnapshot.from(mapOf( + "title" to intent.getStringExtra("title"), "playing" to intent.getBooleanExtra("playing", false), + "positionMs" to intent.getLongExtra("positionMs", 0), "durationMs" to intent.getLongExtra("durationMs", 0) + ), snapshot) + // Android 15 audio-focus eligibility requires foreground state BEFORE focus request. + updateSession() + if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + else startForeground(NOTIFICATION_ID, notification()) + updateFocus() + } + return START_NOT_STICKY + } + + private fun applySnapshot(value: PlaybackSnapshot) { + snapshot = value + updateSession() + updateFocus() + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification()) + } + + private fun updateSession() { + session.setMetadata(MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, snapshot.title) + .putLong(MediaMetadata.METADATA_KEY_DURATION, snapshot.durationMs).build()) + session.setPlaybackState(PlaybackState.Builder().setActions( + PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_STOP or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS + ).setState(if (snapshot.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED, + snapshot.positionMs, if (snapshot.playing) 1f else 0f).build()) + } + + @Suppress("DEPRECATION") + private fun updateFocus() { + if (!snapshot.playing) { if (!interrupted) abandonFocus(); return } + if (focusHeld) return + val granted = if (Build.VERSION.SDK_INT >= 26) { + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build()) + .setWillPauseWhenDucked(true).setOnAudioFocusChangeListener(focusListener).build() + focusRequest = request + audio.requestAudioFocus(request) + } else audio.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) + focusHeld = granted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + if (!focusHeld) NativeMediaEvents.action("pause") + } + + @Suppress("DEPRECATION") + private fun abandonFocus() { + if (Build.VERSION.SDK_INT >= 26) focusRequest?.let { audio.abandonAudioFocusRequest(it) } + else if (focusHeld) audio.abandonAudioFocus(focusListener) + focusHeld = false + focusRequest = null + } + + private fun control(action: String): PendingIntent = PendingIntent.getService(this, action.hashCode(), + Intent(this, BackgroundPlaybackService::class.java).setAction(CONTROL).putExtra("action", action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + private fun notification(): Notification { + val builder = if (Build.VERSION.SDK_INT >= 26) Notification.Builder(this, CHANNEL) else @Suppress("DEPRECATION") Notification.Builder(this) + val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + return builder.setSmallIcon(R.drawable.ic_playback).setContentTitle(snapshot.title).setContentText("Reelbox") + .setContentIntent(open).setVisibility(Notification.VISIBILITY_PUBLIC).setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_TRANSPORT).setOngoing(snapshot.playing) + .setDeleteIntent(control("stop")) + .addAction(Notification.Action.Builder(android.R.drawable.ic_media_previous, "Previous", control("previous")).build()) + .addAction(Notification.Action.Builder(if (snapshot.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, + if (snapshot.playing) "Pause" else "Play", control(if (snapshot.playing) "pause" else "play")).build()) + .addAction(Notification.Action.Builder(android.R.drawable.ic_media_next, "Next", control("next")).build()) + .addAction(Notification.Action.Builder(android.R.drawable.ic_menu_close_clear_cancel, "Stop", control("stop")).build()) + .setStyle(Notification.MediaStyle().setMediaSession(session.sessionToken).setShowActionsInCompactView(0, 1, 2)) + .build() + } + + override fun onTaskRemoved(rootIntent: Intent?) { NativeMediaEvents.action("stop"); stopSelf(); super.onTaskRemoved(rootIntent) } + override fun onDestroy() { + if (running === this) running = null + abandonFocus() + if (noisyRegistered) unregisterReceiver(noisy) + session.isActive = false + session.release() + if (Build.VERSION.SDK_INT >= 24) stopForeground(STOP_FOREGROUND_REMOVE) else @Suppress("DEPRECATION") stopForeground(true) + super.onDestroy() + } + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/EmbeddedVideoActivity.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/EmbeddedVideoActivity.kt new file mode 100644 index 0000000..6aaa1fc --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/EmbeddedVideoActivity.kt @@ -0,0 +1,244 @@ +package org.reelbox.reelbox + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.content.pm.ActivityInfo +import android.graphics.Color +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.Uri +import android.net.http.SslError +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.DownloadListener +import android.webkit.PermissionRequest +import android.webkit.SslErrorHandler +import android.webkit.URLUtil +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import java.io.ByteArrayInputStream + +/** Restricted video embed host, not a vendor storefront or general external browser. */ +class EmbeddedVideoActivity : Activity() { + companion object { private const val FILE_REQUEST = 6104 } + private lateinit var web: WebView + private lateinit var root: LinearLayout + private lateinit var screen: FrameLayout + private lateinit var status: TextView + private lateinit var home: String + private val resourceFetcher = RestrictedResourceFetcher() + @Volatile private var securePage = false + private var customView: View? = null + private var customCallback: WebChromeClient.CustomViewCallback? = null + private var oldOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + private var oldUi = 0 + private var fileCallback: ValueCallback>? = null + private var networkRegistered = false + private lateinit var connectivity: ConnectivityManager + private val network = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = updateConnectivity() + override fun onLost(network: Network) = updateConnectivity() + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = updateConnectivity() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + home = intent.getStringExtra("url").orEmpty() + if (!NativeUrlPolicy.allowsRemote(home)) { finish(); return } + securePage = home.startsWith("https:", true) + title = intent.getStringExtra("title")?.take(512) ?: "Embedded video" + screen = FrameLayout(this) + root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) } + val tools = LinearLayout(this) + fun control(label: String, action: () -> Unit) { + tools.addView(Button(this).apply { text = label; setOnClickListener { action() } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + } + control("Back") { back() } + control("Home") { load(home) } + control("Refresh") { web.reload() } + control("Close") { finish() } + root.addView(tools) + status = TextView(this).apply { setTextColor(Color.WHITE); setPadding(12, 4, 12, 4) } + root.addView(status) + web = WebView(this) + root.addView(web, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + screen.addView(root) + setContentView(screen) + web.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + mediaPlaybackRequiresUserGesture = true + if (Build.VERSION.SDK_INT >= 26) safeBrowsingEnabled = true + } + CookieManager.getInstance().setAcceptThirdPartyCookies(web, false) + web.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val allowed = NativeUrlPolicy.allowsRemote(request.url.toString()) + if (!allowed && request.isForMainFrame) message("This destination is not permitted.") + return !allowed + } + @Suppress("DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = !NativeUrlPolicy.allowsRemote(url) + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse { + val response = resourceFetcher.fetch(request.url.toString(), request.method, request.requestHeaders, + secureContext = !request.isForMainFrame && securePage) + if (request.isForMainFrame) { + if (response.effectiveUrl.isNotEmpty()) securePage = response.effectiveUrl.startsWith("https:", true) + if (response.statusCode in listOf(401, 403, 451)) { + message("This source or destination denied access. Authorization cannot be bypassed.") + } else if (response.statusCode >= 500) message("Video page unavailable. Check connectivity and use Refresh.") + } + return resourceResponse(response) + } + @Suppress("DEPRECATION") + override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse { + return resourceResponse(resourceFetcher.fetch(url, secureContext = securePage)) + } + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { + handler.cancel() + message("Secure connection failed. Playback was not authorized.") + } + override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) { + if (request.isForMainFrame && response.statusCode in listOf(401, 403, 451)) { + view.stopLoading() + message("This source denied access. Reelbox cannot bypass authorization or DRM.") + } + } + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { + if (request.isForMainFrame) message("Video page unavailable. Check connectivity and use Refresh.") + } + override fun onPageFinished(view: WebView, url: String) { updateConnectivity() } + } + web.webChromeClient = object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { request.deny() } + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + if (customView != null) { callback.onCustomViewHidden(); return } + customView = view + customCallback = callback + oldOrientation = requestedOrientation + @Suppress("DEPRECATION") + oldUi = window.decorView.systemUiVisibility + root.visibility = View.GONE + screen.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + override fun onHideCustomView() = hideFullscreen() + override fun onShowFileChooser(view: WebView, callback: ValueCallback>, params: FileChooserParams): Boolean { + fileCallback?.onReceiveValue(null) + fileCallback = callback + val picker = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*") + .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*", "text/vtt", "application/x-subrip")) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { @Suppress("DEPRECATION") startActivityForResult(picker, FILE_REQUEST); true } + catch (_: Exception) { fileCallback?.onReceiveValue(null); fileCallback = null; false } + } + } + web.setDownloadListener(DownloadListener { url, _, contentDisposition, mime, _ -> + val name = URLUtil.guessFileName(url, contentDisposition, mime) + if (!NativeUrlPolicy.allowsRemote(url) || mime.orEmpty().contains("android.package", true) || + Regex("\\.(apk|apks|xapk|aab)$", RegexOption.IGNORE_CASE).containsMatchIn(name)) { + message("This download is not permitted.") + } else AlertDialog.Builder(this).setTitle("Download media?") + .setMessage("Send $name to an installed download manager? Web credentials are not forwarded.") + .setNegativeButton("Cancel", null).setPositiveButton("Choose downloader") { _, _ -> + AlertDialog.Builder(this).setTitle("Download with") + .setItems(arrayOf("ADM", "1DM / IDM", "Other app")) { _, index -> + try { + val ok = NativeFileIntents(this).openDownloader(mapOf("url" to url, "filename" to name, + "packageName" to listOf("ADM", "IDM", "ask")[index])) + if (!ok) message("No compatible download manager is installed.") + } catch (_: Exception) { message("Unable to hand off this download.") } + }.show() + }.show() + }) + connectivity = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + try { connectivity.registerDefaultNetworkCallback(network); networkRegistered = true } catch (_: Exception) { } + if (Build.VERSION.SDK_INT >= 33) onBackInvokedDispatcher.registerOnBackInvokedCallback( + android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT) { back() } + if (savedInstanceState != null) web.restoreState(savedInstanceState) else load(home) + updateConnectivity() + } + + private fun load(url: String) { + if (NativeUrlPolicy.allowsRemote(url)) { hideFullscreen(); web.loadUrl(url) } + else message("This destination is not permitted.") + } + private fun resourceResponse(response: RestrictedResourceFetcher.Response): WebResourceResponse = try { + WebResourceResponse(response.mimeType, response.encoding, response.statusCode, + response.reasonPhrase, response.headers, response.body) + } catch (_: IllegalArgumentException) { + // Never fall back to WebView networking if Android rejects response metadata. + try { response.body.close() } catch (_: Exception) { } + WebResourceResponse("text/plain", "UTF-8", 502, "Unavailable", emptyMap(), ByteArrayInputStream(ByteArray(0))) + } + private fun message(text: String) { runOnUiThread { if (::status.isInitialized) status.text = text } } + private fun updateConnectivity() { + if (!::connectivity.isInitialized) return + val caps = connectivity.getNetworkCapabilities(connectivity.activeNetwork) + message(if (caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) "Embedded video · network connected" else "Offline · reconnect and tap Refresh") + } + private fun hideFullscreen() { + val view = customView ?: return + screen.removeView(view) + customView = null + root.visibility = View.VISIBLE + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = oldUi + requestedOrientation = oldOrientation + customCallback?.onCustomViewHidden() + customCallback = null + } + private fun back() { if (customView != null) hideFullscreen() else if (web.canGoBack()) web.goBack() else finish() } + @Deprecated("System back uses OnBackInvokedDispatcher on Android 13+") + override fun onBackPressed() = back() + override fun onSaveInstanceState(outState: Bundle) { if (::web.isInitialized) web.saveState(outState); super.onSaveInstanceState(outState) } + @Deprecated("Activity result contract retained for the platform WebView file chooser") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != FILE_REQUEST) return + val uris = if (resultCode == RESULT_OK) { + val clips = data?.clipData + if (clips != null) (0 until clips.itemCount.coerceAtMost(10)).map { clips.getItemAt(it).uri } + else listOfNotNull(data?.data) + } else emptyList() + fileCallback?.onReceiveValue(uris.filter { it.scheme == "content" }.takeIf { it.isNotEmpty() }?.toTypedArray()) + fileCallback = null + } + override fun onPause() { if (::web.isInitialized) web.onPause(); super.onPause() } + override fun onResume() { super.onResume(); if (::web.isInitialized) web.onResume() } + override fun onDestroy() { + if (networkRegistered) connectivity.unregisterNetworkCallback(network) + fileCallback?.onReceiveValue(null) + fileCallback = null + hideFullscreen() + if (::web.isInitialized) { + web.stopLoading() + (web.parent as? ViewGroup)?.removeView(web) + web.destroy() + } + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/MainActivity.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/MainActivity.kt new file mode 100644 index 0000000..ef53929 --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/MainActivity.kt @@ -0,0 +1,175 @@ +package org.reelbox.reelbox + +import android.app.PictureInPictureParams +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.util.Rational +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/** Window-scoped brightness, system stream volume, ACTION_VIEW and Android PiP. + * No overlay, storage, write-settings or broad media permissions are requested. */ +class MainActivity : FlutterActivity() { + private lateinit var mediaChannel: MethodChannel + private var playerActive = false + private var nativePlayerRequestId: String? = null + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + nativePlayerRequestId = savedInstanceState?.getString(NativePlayerRequests.REQUEST_ID) + super.onCreate(savedInstanceState) + } + + override fun onSaveInstanceState(outState: android.os.Bundle) { + nativePlayerRequestId?.let { outState.putString(NativePlayerRequests.REQUEST_ID, it) } + super.onSaveInstanceState(outState) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != NativePlayerRequests.REQUEST_CODE) return + val id = data?.getStringExtra(NativePlayerRequests.REQUEST_ID) ?: nativePlayerRequestId ?: return + if (id != NativePlayerRequests.results.activeId) return + if (data?.hasExtra("positionMs") == true) { + NativePlayerRequests.results.update(id, data.getLongExtra("positionMs", 0), + data.getLongExtra("durationMs", 0), data.getBooleanExtra("opened", false), + data.getBooleanExtra("completed", false)) + } + nativePlayerRequestId = null + NativePlayerRequests.results.finish(id) + } + private var cast: ReelboxCast? = null + private fun castBridge(): ReelboxCast = cast ?: ReelboxCast(this, mediaChannel).also { cast = it } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + mediaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "org.reelbox/media") + NativeMediaEvents.channel = mediaChannel + mediaChannel.setMethodCallHandler { call, result -> + try { + when (call.method) { + "setPlayerActive" -> { + playerActive = call.argument("active") ?: false + result.success(null) + } + "isInPip" -> result.success(Build.VERSION.SDK_INT >= 26 && isInPictureInPictureMode) + "enterPip" -> result.success(enterPlayerPip()) + "getVolume" -> { + val audio = getSystemService(AUDIO_SERVICE) as AudioManager + val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + result.success(if (max > 0) audio.getStreamVolume(AudioManager.STREAM_MUSIC).toDouble() / max else 0.0) + } + "setVolume" -> { + val audio = getSystemService(AUDIO_SERVICE) as AudioManager + val value = (call.argument("value")?.toDouble() ?: 0.5).coerceIn(0.0, 1.0) + val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + audio.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt(), 0) + result.success(null) + } + "getBrightness" -> result.success(window.attributes.screenBrightness.toDouble()) + "getEffectiveBrightness" -> { + val windowValue = window.attributes.screenBrightness + val value = if (windowValue >= 0) windowValue.toDouble() else + android.provider.Settings.System.getInt(contentResolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS, 128) / 255.0 + result.success(value.coerceIn(0.0, 1.0)) + } + "setBrightness" -> { + val value = call.argument("value")?.toFloat() ?: -1f + val attributes = window.attributes + attributes.screenBrightness = if (value < 0) -1f else value.coerceIn(0.01f, 1f) + window.attributes = attributes + result.success(null) + } + "backgroundPlayback" -> { + if (call.argument("enabled") == true) { + if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 3041) + } + BackgroundPlaybackService.start(this, call.arguments as? Map<*, *> ?: emptyMap()) + } else BackgroundPlaybackService.stop(this) + result.success(null) + } + "backgroundPlaybackUpdate" -> { + BackgroundPlaybackService.update(call.arguments as? Map<*, *> ?: emptyMap()) + result.success(null) + } + "backgroundPlaybackStop" -> { BackgroundPlaybackService.stop(this); result.success(null) } + "castShowPicker" -> result.success(castBridge().showPicker()) + "castGetState" -> result.success(castBridge().getState()) + "castLoad" -> castBridge().load(call.arguments as? Map<*, *> ?: emptyMap(), result) + "castCommand" -> castBridge().command(call.arguments as? Map<*, *> ?: emptyMap(), result) + "castDisconnect" -> { castBridge().disconnect(); result.success(null) } + "openNativePlayer" -> { + val launch = NativePlayerLaunch.from(call.arguments as? Map<*, *> ?: emptyMap(), + VlcPlayerActivity.ownedRoots(this)) + val id = java.util.UUID.randomUUID().toString() + if (!NativePlayerRequests.results.begin(id, launch.positionMs) { result.success(it) }) { + result.error("NATIVE_PLAYER_BUSY", "A native player is already open.", null) + } else { + nativePlayerRequestId = id + try { + startActivityForResult(VlcPlayerActivity.intent(this, launch, id), NativePlayerRequests.REQUEST_CODE) + } catch (_: Exception) { + NativePlayerRequests.results.finish(id) + nativePlayerRequestId = null + } + } + } + "openExternal" -> result.success(NativeFileIntents(this).openExternal(call.arguments as? Map<*, *> ?: emptyMap())) + "openDownloader" -> result.success(NativeFileIntents(this).openDownloader(call.arguments as? Map<*, *> ?: emptyMap())) + "revealFile" -> result.success(NativeFileIntents(this).revealFile(call.arguments as? Map<*, *> ?: emptyMap())) + "openEmbedded" -> { + val url = call.argument("url").orEmpty() + require(NativeUrlPolicy.allowsRemote(url)) + startActivity(Intent(this, EmbeddedVideoActivity::class.java).putExtra("url", url) + .putExtra("title", call.argument("title").orEmpty())) + result.success(true) + } + else -> result.notImplemented() + } + } catch (error: Exception) { + result.error("MEDIA_PLATFORM", "Android could not perform this media operation.", null) + } + } + } + + override fun onDestroy() { + // Keep a pending reply through host configuration changes; a permanently + // closing host must not leak the result callback or native activity. + if (!isChangingConfigurations && nativePlayerRequestId != null && + nativePlayerRequestId == NativePlayerRequests.results.activeId) { + finishActivity(NativePlayerRequests.REQUEST_CODE) + NativePlayerRequests.results.dispose() + nativePlayerRequestId = null + } + cast?.dispose() + cast = null + if (::mediaChannel.isInitialized && NativeMediaEvents.channel === mediaChannel) { + BackgroundPlaybackService.stop(this) + NativeMediaEvents.channel = null + mediaChannel.setMethodCallHandler(null) + } + super.onDestroy() + } + + private fun enterPlayerPip(): Boolean { + if (Build.VERSION.SDK_INT < 26 || !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false + return enterPictureInPictureMode(PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build()) + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (playerActive) enterPlayerPip() + } + + override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(inPip, newConfig) + if (::mediaChannel.isInitialized) mediaChannel.invokeMethod("pipChanged", inPip) + } +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/NativeFileIntents.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/NativeFileIntents.kt new file mode 100644 index 0000000..8f94b26 --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/NativeFileIntents.kt @@ -0,0 +1,111 @@ +package org.reelbox.reelbox + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.Intent +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import java.io.File + +/** Only scoped content grants leave the app; never file:// or broad storage grants. */ +class NativeFileIntents(private val activity: Activity) { + private fun localFile(path: String): File { + val file = File(path).canonicalFile + val roots = listOfNotNull(activity.filesDir, activity.cacheDir, + activity.getExternalFilesDir(null), activity.externalCacheDir).map { it.canonicalFile } + require(roots.any { file.path.startsWith(it.path + File.separator) } && file.isFile && file.canRead()) { "File is not in app-owned storage." } + require(!file.name.endsWith(".apk", true) && !file.name.endsWith(".apks", true) && !file.name.endsWith(".xapk", true) && !file.name.endsWith(".aab", true)) + return file + } + + private fun shareUri(file: File): Uri = FileProvider.getUriForFile(activity, "${activity.packageName}.files", file) + + private fun mediaUri(value: String): Uri { + val parsed = Uri.parse(value) + return when (parsed.scheme?.lowercase()) { + "http", "https" -> { require(NativeUrlPolicy.allowsRemote(value)); parsed } + "content" -> { + require(!parsed.authority.isNullOrBlank()) + // Confirm this process really has read access; forwarding arbitrary provider URIs is forbidden. + activity.contentResolver.openAssetFileDescriptor(parsed, "r")?.use { } ?: throw IllegalArgumentException("Unreadable content URI.") + parsed + } + "file" -> shareUri(localFile(parsed.path ?: "")) + null -> shareUri(localFile(value)) + else -> throw IllegalArgumentException("Unsupported media URI.") + } + } + + private fun grant(intent: Intent, uris: List) { + val content = uris.filter { it.scheme == "content" } + if (content.isEmpty()) return + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.clipData = ClipData.newRawUri("Reelbox media", content.first()).also { clip -> + content.drop(1).forEach { clip.addItem(ClipData.Item(it)) } + } + } + + private fun launch(intent: Intent, packageName: String?, title: String): Boolean { + if (!packageName.isNullOrBlank() && packageName != "ask") { + require(NativeUrlPolicy.safePackage(packageName)) + try { activity.startActivity(Intent(intent).setPackage(packageName)); return true } + catch (_: ActivityNotFoundException) { /* Offer an installed alternative. */ } + } + return try { + activity.startActivity(Intent.createChooser(Intent(intent).setPackage(null), title)); true + } catch (_: ActivityNotFoundException) { false } + } + + fun openExternal(args: Map<*, *>): Boolean { + val uri = mediaUri(args["url"] as? String ?: "") + val subtitle = (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }?.let { mediaUri(it) } + val position = ((args["positionMs"] as? Number)?.toLong() ?: 0).coerceAtLeast(0) + val requested = args["packageName"] as? String + val target = when (requested?.lowercase()) { + "vlc" -> "org.videolan.vlc" + "mx", "mxplayer" -> "com.mxtech.videoplayer.ad" + else -> requested + } + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, "video/*") + .putExtra(Intent.EXTRA_TITLE, (args["title"] as? String).orEmpty().take(512)) + // VLC's public Android API uses a long position; MX Player uses int milliseconds. + if (target?.startsWith("com.mxtech.") == true) intent.putExtra("position", position.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) + else intent.putExtra("position", position) + if (subtitle != null) { + intent.putExtra("subtitles_location", subtitle.toString()) // VLC Android public API. + intent.putExtra("subs", arrayOf(subtitle)) // MX Player public API. + intent.putExtra("subs.enable", arrayOf(subtitle)) + } + grant(intent, listOfNotNull(uri, subtitle)) + return launch(intent, target, "Open video with") + } + + fun revealFile(args: Map<*, *>): Boolean { + val file = localFile(args["path"] as? String ?: "") + val uri = shareUri(file) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension.lowercase()) ?: "application/octet-stream" + // Shares/export via Android's scoped chooser (including installed document/save targets). + val intent = Intent(Intent.ACTION_SEND).setType(mime).putExtra(Intent.EXTRA_STREAM, uri) + .putExtra(Intent.EXTRA_TITLE, file.name) + grant(intent, listOf(uri)) + return launch(intent, "ask", "Export ${file.name}") + } + + fun openDownloader(args: Map<*, *>): Boolean { + val url = args["url"] as? String ?: "" + require(NativeUrlPolicy.allowsRemote(url)) + val requested = args["packageName"] as? String ?: "ask" + val target = when (requested.lowercase()) { + "adm" -> "com.dv.adm" + "idm", "1dm" -> "idm.internet.download.manager" + else -> requested + } + val name = (args["filename"] as? String).orEmpty().substringAfterLast('/').substringAfterLast('\\').take(240) + require(!Regex("\\.(apk|apks|xapk|aab)$", RegexOption.IGNORE_CASE).containsMatchIn(name)) + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(Uri.parse(url), "application/octet-stream") + .putExtra(Intent.EXTRA_TITLE, name) + return launch(intent, target, "Send download to") + } +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/NativePlayerPolicy.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/NativePlayerPolicy.kt new file mode 100644 index 0000000..d682c6b --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/NativePlayerPolicy.kt @@ -0,0 +1,119 @@ +package org.reelbox.reelbox + +import java.io.File +import java.net.URI +import java.util.Locale + +/** Immutable launch contract; remote source strings are never rewritten. */ +data class NativePlayerLaunch( + val url: String, + val title: String, + val positionMs: Long, + val autoplay: Boolean, + val externalPackage: String, + val orientation: String, + val videoFit: String, + val speed: Float, + val subtitleEnabled: Boolean, + val subtitleUrl: String?, + val hardwareAcceleration: Boolean, + val pipEnabled: Boolean, +) { + companion object { + /** Accept remote HTTP(S), including a parent-owned loopback HLS lease, + * and readable canonical files strictly inside app-owned roots. No content + * providers, file authorities, APKs, arbitrary paths or URL credentials. */ + fun source(value: String, roots: List): String { + require(value.isNotBlank() && value.none { it.code < 32 || it.code == 127 }) + if (value.startsWith('/')) return ownedFile(File(value), roots) + val uri = URI(value) + return when (uri.scheme?.lowercase(Locale.ROOT)) { + "http", "https" -> { + require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null && !value.contains('\\')) + // A loopback lease is generated by this app, not catalog traffic. + val loopback = uri.host in listOf("127.0.0.1", "[::1]", "::1") + require(loopback || NativeUrlPolicy.allowsRemote(value)) + value + } + "file" -> { + require(uri.rawAuthority.isNullOrEmpty() && uri.rawQuery == null && uri.rawFragment == null) + ownedFile(File(uri), roots) + } + else -> throw IllegalArgumentException("Unsupported media source") + } + } + + private fun ownedFile(file: File, roots: List): String { + val canonical = file.canonicalFile + require(roots.any { canonical.path.startsWith(it.canonicalPath + File.separator) }) + require(canonical.isFile && canonical.canRead() && NativeUrlPolicy.allowsSharedFilename(canonical.name)) + return canonical.path + } + + fun from(args: Map<*, *>, roots: List): NativePlayerLaunch { + val url = source(args["url"] as? String ?: "", roots) + val speed = (args["speed"] as? Number)?.toFloat() ?: 1f + require(speed.isFinite() && speed in 0.25f..3f) + val orientation = (args["orientation"] as? String) ?: "auto" + require(orientation in listOf("auto", "portrait", "landscape")) + val fit = (args["videoFit"] as? String) ?: "contain" + require(fit in listOf("contain", "cover", "fill")) + val external = (args["externalPackage"] as? String) ?: "ask" + require(external in listOf("ask", "vlc", "mx", "mxplayer") || NativeUrlPolicy.safePackage(external)) + val position = args["positionMs"] as? Number + require(position == null || position.toDouble().isFinite()) + return NativePlayerLaunch(url, (args["title"] as? String).orEmpty().take(512), + (position?.toLong() ?: 0).coerceAtLeast(0), args["autoplay"] != false, + external, orientation, fit, speed, args["subtitleEnabled"] != false, + (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }?.let { source(it, roots) }, + args["hardwareAcceleration"] != false, args["pipEnabled"] != false) + } + } +} + +/** Main-thread owned, process-retained rendezvous. Activity recreation does not + * resolve the Flutter call. Stale/duplicate activity results cannot consume a + * newer request. Clear before invoking callback to make reentrancy safe. */ +class NativePlayerResultGate { + private data class Pending(val id: String, val reply: (Map) -> Unit, + var position: Long, var duration: Long = 0, var opened: Boolean = false, var completed: Boolean = false) + private var pending: Pending? = null + val activeId: String? get() = pending?.id + + fun begin(id: String, positionMs: Long, reply: (Map) -> Unit): Boolean { + if (pending != null) return false + pending = Pending(id, reply, positionMs.coerceIn(0, Long.MAX_VALUE / 1000L)) + return true + } + + fun update(id: String, positionMs: Long, durationMs: Long, opened: Boolean, completed: Boolean): Boolean { + val item = pending?.takeIf { it.id == id } ?: return false + // Flutter Duration converts milliseconds to signed 64-bit microseconds. + item.duration = durationMs.coerceIn(0, Long.MAX_VALUE / 1000L) + item.position = positionMs.coerceIn(0, Long.MAX_VALUE / 1000L).let { if (item.duration > 0) it.coerceAtMost(item.duration) else it } + item.opened = item.opened || opened + item.completed = item.opened && completed + return true + } + + fun finish(id: String): Boolean { + val item = pending?.takeIf { it.id == id } ?: return false + pending = null + // A permanently detached Flutter engine may reject a reply. The slot is + // still consumed exactly once; never retain an Activity/messenger or crash + // Android teardown while trying to notify an isolate that no longer exists. + try { + item.reply(mapOf("positionMs" to item.position, "durationMs" to item.duration, + "opened" to item.opened, "completed" to item.completed)) + } catch (_: RuntimeException) { } + return true + } + + fun dispose() { activeId?.let { finish(it) } } +} + +object NativePlayerRequests { + val results = NativePlayerResultGate() + const val REQUEST_CODE = 3047 + const val REQUEST_ID = "reelbox.nativePlayerRequestId" +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/NativeUrlPolicy.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/NativeUrlPolicy.kt new file mode 100644 index 0000000..3182938 --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/NativeUrlPolicy.kt @@ -0,0 +1,48 @@ +package org.reelbox.reelbox + +import java.net.URI +import java.net.URLDecoder +import java.text.Normalizer +import java.util.Locale + +/** Outgoing media/browser policy only; never applied to catalog API traffic. */ +object NativeUrlPolicy { + private val forbiddenHosts = listOf("t.me", "telegram.me", "telegram.org", "telegra.ph", "telegram.dog") + private val forbiddenPath = Regex("(^|[/_.?&=\\-])(apk|apks|xapk|aab|update|updates|updater|version|versions|support|contact|news|shop|store|telegram|subscribe|subscription|payment|login|register|drm|license)([/_.?&=\\-]|$)") + private val formatting = Regex("[\\p{Cf}\\u064b-\\u065f\\u0670\\u06d6-\\u06ed]") + private val entities = Regex("&#(x[0-9a-f]+|[0-9]+);", RegexOption.IGNORE_CASE) + private fun decode(value: String): String { + var decoded = value + repeat(3) { decoded = URLDecoder.decode(decoded, "UTF-8") } + return entities.replace(decoded) { match -> + val token = match.groupValues[1] + val number = if (token.startsWith("x", true)) token.drop(1).toIntOrNull(16) else token.toIntOrNull() + if (number != null && Character.isValidCodePoint(number)) String(Character.toChars(number)) else "" + }.replace("&", "&").replace(" ", " ") + } + private fun normal(value: String): String = Normalizer.normalize(decode(value), Normalizer.Form.NFKC) + .lowercase(Locale.ROOT).replace('ي', 'ی').replace('ى', 'ی').replace('ك', 'ک').replace(formatting, "") + + fun allowsRemote(value: String): Boolean = try { + val uri = URI(value) + val scheme = uri.scheme?.lowercase(Locale.ROOT) + val host = uri.host?.lowercase(Locale.ROOT)?.trimEnd('.') ?: "" + if (scheme !in listOf("http", "https") || host.isBlank() || uri.rawUserInfo != null || value.any { it.code <= 32 || it.code == 127 }) false + else { + val decoded = normal(value) + val compact = decoded.replace(Regex("[\\s_\\-\\u0640]"), "") + val vendor = listOf("melodybox", "melodibox", "norton", "ملودیباکس", "ملودیبکس", "نورتون", "نورتن").any { compact.contains(it) } + !vendor && !decoded.contains('\\') && !decoded.contains("تلگرام") && + listOf("tg:", "telegram:", "intent:", "javascript:", "data:", "file:").none { decoded.contains(it) } && + forbiddenHosts.none { host == it || host.endsWith(".$it") || decoded.contains("//$it/") || decoded.endsWith("//$it") } && + !forbiddenPath.containsMatchIn(normal(uri.rawPath.orEmpty() + "?" + uri.rawQuery.orEmpty())) && + !Regex("\\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)([?#/]|$)").containsMatchIn(decoded) + } + } catch (_: Exception) { false } + + fun allowsSharedFilename(value: String): Boolean = try { + !Regex("\\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)$").containsMatchIn(normal(value).trim().trimEnd('.')) + } catch (_: Exception) { false } + + fun safePackage(value: String): Boolean = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+").matches(value) +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/PlaybackSnapshot.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/PlaybackSnapshot.kt new file mode 100644 index 0000000..c4b5e3f --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/PlaybackSnapshot.kt @@ -0,0 +1,20 @@ +package org.reelbox.reelbox + +/** Decoder truth supplied by Dart, never inferred by a native timer/player. */ +data class PlaybackSnapshot( + val title: String = "Reelbox", val playing: Boolean = false, + val positionMs: Long = 0, val durationMs: Long = 0 +) { + companion object { + fun from(args: Map<*, *>, previous: PlaybackSnapshot = PlaybackSnapshot()): PlaybackSnapshot { + val duration = ((args["durationMs"] as? Number)?.toLong() ?: previous.durationMs).coerceAtLeast(0) + val position = ((args["positionMs"] as? Number)?.toLong() ?: previous.positionMs).coerceAtLeast(0) + return PlaybackSnapshot( + (args["title"] as? String ?: previous.title).take(512), + args["playing"] as? Boolean ?: previous.playing, + if (duration > 0) position.coerceAtMost(duration) else position, + duration + ) + } + } +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/PlayerControlsState.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/PlayerControlsState.kt new file mode 100644 index 0000000..203f3e6 --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/PlayerControlsState.kt @@ -0,0 +1,128 @@ +package org.reelbox.reelbox + +/** Distinguishes asynchronous dismissal callbacks from replaced native sheets. */ +class PlayerMenuSession { + private var current: Any? = null + fun open(): Any = Any().also { current = it } + fun close(owner: Any): Boolean { + if (current !== owner) return false + current = null + return true + } +} + +/** Timeline conversion shared by native controls and relative seek callbacks. */ +object PlayerSeekMath { + fun fromProgress(durationMs: Long, progress: Int): Long { + val duration = durationMs.coerceAtLeast(0) + val fraction = progress.coerceIn(0, 10000).toLong() + // Each product is bounded independently; duration * fraction may overflow. + return (duration / 10000L) * fraction + (duration % 10000L) * fraction / 10000L + } + + fun relative(positionMs: Long, deltaMs: Long): Long { + val position = positionMs.coerceAtLeast(0) + return if (deltaMs > 0 && position > Long.MAX_VALUE - deltaMs) Long.MAX_VALUE + else (position + deltaMs).coerceAtLeast(0) + } +} + +/** + * Deterministic chrome policy. No Android dependency, wall clock, or timer. + * The view supplies monotonic milliseconds and schedules only [deadlineMs]. + * Playback snapshots do not count as interaction and cannot extend a deadline. + */ +class PlayerControlsState { + private var visible = true + private var playing = false + private var loading = false + private var error = false + private var pip = false + private var inForeground = true + private var seeking = false + private var menuOpen = false + private var released = false + var locked = false + private set + val chromeVisible: Boolean get() = visible && !pip && !locked && !released + val unlockVisible: Boolean get() = locked && !pip && !released + var deadlineMs: Long? = null + private set + + fun playback(playing: Boolean, loading: Boolean, error: Boolean, pip: Boolean, now: Long) { + if (released) return + val exitingPip = this.pip && !pip + val newError = error && !this.error + this.playing = playing + this.loading = loading + this.error = error + this.pip = pip + if (exitingPip || newError) visible = true + if (newError) { locked = false; seeking = false; menuOpen = false } + schedule(now) + } + + fun tap(now: Long) { + if (released || pip || locked || error || seeking || menuOpen) return + visible = !visible + deadlineMs = null + schedule(now) + } + + fun interact(now: Long) { + if (released || pip || locked) return + visible = true + deadlineMs = null + schedule(now) + } + + fun menu(open: Boolean, now: Long) { + if (released || menuOpen == open) return + menuOpen = open + if (open) visible = true + deadlineMs = null + schedule(now) + } + + fun seeking(active: Boolean, now: Long) { + if (released || seeking == active) return + seeking = active + if (active) visible = true + deadlineMs = null + schedule(now) + } + + fun lock(locked: Boolean, now: Long) { + if (released || pip || error) return + this.locked = locked + visible = !locked + deadlineMs = null + schedule(now) + } + + fun foreground(foreground: Boolean, now: Long) { + if (released || inForeground == foreground) return + inForeground = foreground + deadlineMs = null + schedule(now) + } + + fun tick(now: Long) { + if (!released && deadlineMs?.let { now >= it } == true) { + visible = false + deadlineMs = null + } + } + + fun release() { + released = true + visible = false + deadlineMs = null + } + + private fun schedule(now: Long) { + if (!playing || loading || error || pip || locked || seeking || menuOpen || !inForeground || !chromeVisible) { + deadlineMs = null + } else if (deadlineMs == null) deadlineMs = now + 3000L + } +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/PlayerOverlayView.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/PlayerOverlayView.kt new file mode 100644 index 0000000..c8385b5 --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/PlayerOverlayView.kt @@ -0,0 +1,617 @@ +package org.reelbox.reelbox + +import android.app.Dialog +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.ClipDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.RippleDrawable +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.text.TextUtils +import android.view.GestureDetector +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.Window +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.SeekBar +import android.widget.TextView +import java.util.Locale + +/** Immutable engine snapshot. Repeated snapshots are not user interactions. */ +data class PlayerOverlayState( + val title: String, + val positionMs: Long = 0L, + val durationMs: Long = 0L, + val playing: Boolean = false, + val loading: Boolean = false, + val error: Boolean = false, + val seekable: Boolean = false, + val speed: Float = 1f, + val pip: Boolean = false, +) + +/** Cinema chrome only: no decoder, URL, history, or activity ownership. */ +class PlayerOverlayView(context: Context, private val callbacks: Callbacks) : FrameLayout(context) { + interface Callbacks { + fun onClose() + fun onTogglePlayback() + fun onSeekRelative(deltaMs: Long) + fun onSeekTo(positionMs: Long) + fun onSettings() + fun onExternal() + fun onRetry(software: Boolean) + fun onPictureInPicture() + } + + private val handler = Handler(Looper.getMainLooper()) + private val controls = PlayerControlsState() + private var state = PlayerOverlayState("") + private var released = false + private var dragging = false + private var cancellingDrag = false + private var suppressCancelledTouch = false + private var scheduledDeadline: Long? = null + private val safeInsets = android.graphics.Rect() + private val header = LinearLayout(context) + private val gestures = View(context).apply { + tag = "player-video-gestures" + contentDescription = "Video. Tap for controls; double-tap left or right to seek ten seconds" + isClickable = true + isFocusable = true + } + private val chrome = LinearLayout(context).apply { + tag = "player-controls" + orientation = LinearLayout.VERTICAL + clipChildren = true + } + private val title = label(16f).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + private val play = icon("play", "Play", "player-play") { callbacks.onTogglePlayback() } + private val back = icon("back", "Close player", "player-back") { callbacks.onClose() } + private val external = icon("external", "Open external player", "player-external") { callbacks.onExternal() } + private val settings = icon("settings", "Player settings", "player-settings") { callbacks.onSettings() } + private val lock = icon("lock", "Lock screen controls", "player-lock") { + controls.lock(true, now()); updateVisibility() + } + private val unlock = icon("unlock", "Unlock screen controls", "player-unlock") { + controls.lock(false, now()); updateVisibility() + } + private val rewind = icon("rewind", "Rewind ten seconds", "player-rewind") { callbacks.onSeekRelative(-10000L) } + private val forward = icon("forward", "Forward ten seconds", "player-forward") { callbacks.onSeekRelative(10000L) } + private val pip = icon("pip", "Picture in picture", "player-pip") { callbacks.onPictureInPicture() } + private val elapsed = label(12f).apply { tag = "player-clock"; gravity = Gravity.CENTER } + private val remaining = label(12f).apply { tag = "player-remaining"; gravity = Gravity.CENTER } + private val speed = label(12f).apply { gravity = Gravity.CENTER_VERTICAL; setTextColor(0xFFD0D0D0.toInt()) } + private val seek = SeekBar(context).apply { + tag = "player-seek" + contentDescription = "Seek video" + max = 10000 + progressTintList = ColorStateList.valueOf(CORAL) + progressBackgroundTintList = ColorStateList.valueOf(0xFF666666.toInt()) + thumbTintList = ColorStateList.valueOf(Color.WHITE) + splitTrack = false + minimumHeight = dp(48) + // Keep a 48dp touch area and a 2dp track, without API-29 min/max + // ProgressBar setters (the player also supports Android 24). + progressDrawable = LayerDrawable(arrayOf( + playerSurface(0xFF666666.toInt(), dp(1).toFloat()), + ClipDrawable(playerSurface(CORAL, dp(1).toFloat()), Gravity.LEFT, ClipDrawable.HORIZONTAL), + )).apply { + setId(0, android.R.id.background); setId(1, android.R.id.progress) + for (layer in 0..1) { setLayerHeight(layer, dp(2)); setLayerGravity(layer, Gravity.CENTER_VERTICAL) } + } + thumb = GradientDrawable().apply { + shape = GradientDrawable.OVAL; setColor(Color.WHITE); setSize(dp(10), dp(10)) + } + } + private val loading = ProgressBar(context).apply { + tag = "player-loading" + contentDescription = "Buffering video" + indeterminateTintList = ColorStateList.valueOf(Color.WHITE) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + } + private val errorPanel = LinearLayout(context).apply { + tag = "player-error" + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(16), dp(20), dp(12)) + background = playerSurface(0xF0242424.toInt(), dp(16).toFloat()) + } + private val errorScroll = ScrollView(context).apply { isFillViewport = false; addView(errorPanel) } + private val center = FrameLayout(context) + private val transport = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL } + private val bottom = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + private val feedback = label(14f).apply { + gravity = Gravity.CENTER + setPadding(dp(12), dp(8), dp(12), dp(8)) + background = playerSurface(0xD0242424.toInt(), dp(16).toFloat()) + visibility = View.GONE + } + private val hideFeedback = Runnable { if (!released) feedback.visibility = View.GONE } + private val hide = Runnable { + scheduledDeadline = null + if (!released) { controls.tick(now()); updateVisibility() } + } + private val detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent) = true + override fun onSingleTapConfirmed(event: MotionEvent): Boolean { + if (!released) gestures.performClick() + return true + } + override fun onDoubleTap(event: MotionEvent): Boolean { + if (!released && !state.pip && !controls.locked && !state.error && state.seekable) { + val delta = if (event.x < gestures.width / 2f) -10000L else 10000L + controls.interact(now()) + callbacks.onSeekRelative(delta) + if (released) return true + feedback.text = if (delta < 0) "−10 s" else "+10 s" + (feedback.layoutParams as LayoutParams).apply { + gravity = Gravity.CENTER_VERTICAL or if (delta < 0) Gravity.LEFT else Gravity.RIGHT + leftMargin = dp(24); rightMargin = dp(24) + feedback.layoutParams = this + } + feedback.visibility = View.VISIBLE + handler.removeCallbacks(hideFeedback) + handler.postDelayed(hideFeedback, 700) + updateVisibility() + } + return true + } + }, handler).apply { setIsLongpressEnabled(false) } + + init { + // The only gesture detector is a sibling BELOW chrome. Buttons receive + // ACTION_UP immediately; they never wait for double-tap confirmation. + addView(gestures, LayoutParams(-1, -1)) + addView(chrome, LayoutParams(-1, -1)) + header.apply { + gravity = Gravity.CENTER_VERTICAL + background = scrim(true) + addView(back, LinearLayout.LayoutParams(dp(48), dp(48))) + addView(title, LinearLayout.LayoutParams(0, dp(48), 1f)) + addView(View(context), LinearLayout.LayoutParams(dp(32), dp(48))) + addView(external, LinearLayout.LayoutParams(dp(48), dp(48))) + } + chrome.addView(header, LinearLayout.LayoutParams(-1, dp(48))) + transport.addView(rewind, LinearLayout.LayoutParams(dp(48), dp(48))) + transport.addView(play, LinearLayout.LayoutParams(dp(64), dp(64)).apply { + leftMargin = dp(20); rightMargin = dp(20) + }) + transport.addView(forward, LinearLayout.LayoutParams(dp(48), dp(48))) + play.background = playerSurface(0xB8242424.toInt(), dp(32).toFloat()) + center.addView(transport, LayoutParams(-2, -2, Gravity.CENTER)) + chrome.addView(center, LinearLayout.LayoutParams(-1, 0, 1f)) + bottom.background = scrim(false) + val timeline = LinearLayout(context).apply { + gravity = Gravity.CENTER_VERTICAL + addView(elapsed, LinearLayout.LayoutParams(dp(60), dp(48))) + addView(seek, LinearLayout.LayoutParams(0, dp(48), 1f)) + addView(remaining, LinearLayout.LayoutParams(dp(68), dp(48))) + } + bottom.addView(timeline, LinearLayout.LayoutParams(-1, dp(48))) + val footer = LinearLayout(context).apply { + gravity = Gravity.CENTER_VERTICAL + addView(lock, LinearLayout.LayoutParams(dp(48), dp(48))) + addView(speed, LinearLayout.LayoutParams(0, dp(48), 1f)) + addView(pip, LinearLayout.LayoutParams(dp(48), dp(48))) + addView(settings, LinearLayout.LayoutParams(dp(48), dp(48))) + } + bottom.addView(footer, LinearLayout.LayoutParams(-1, dp(48))) + chrome.addView(bottom, LinearLayout.LayoutParams(-1, -2)) + errorPanel.addView(label(18f).apply { + text = "Unable to play this video" + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, LinearLayout.LayoutParams(-1, -2)) + errorPanel.addView(label(14f).apply { + text = "Check your connection and retry, or open this source in another player." + setTextColor(0xFFCCCCCC.toInt()) + setPadding(0, dp(8), 0, dp(12)) + }, LinearLayout.LayoutParams(-1, -2)) + val recovery = LinearLayout(context) + recovery.addView(action("Retry", "player-retry") { callbacks.onRetry(false) }, + LinearLayout.LayoutParams(0, dp(48), 1f).apply { rightMargin = dp(8) }) + recovery.addView(action("External player", "player-error-external") { callbacks.onExternal() }, + LinearLayout.LayoutParams(0, dp(48), 1f)) + errorPanel.addView(recovery, LinearLayout.LayoutParams(-1, -2)) + errorPanel.addView(action("Try software decoding", "player-retry-software", subtle = true) { + callbacks.onRetry(true) + }, LinearLayout.LayoutParams(-1, dp(48))) + addView(errorScroll, LayoutParams(-1, -2, Gravity.CENTER).apply { + leftMargin = dp(20); rightMargin = dp(20) + topMargin = dp(56); bottomMargin = dp(8) + }) + addView(loading, LayoutParams(dp(24), dp(24), Gravity.CENTER)) + addView(unlock, LayoutParams(dp(48), dp(48), Gravity.RIGHT or Gravity.CENTER_VERTICAL).apply { + rightMargin = dp(16) + }) + unlock.background = playerSurface(0xB8242424.toInt(), dp(24).toFloat()) + addView(feedback, LayoutParams(-2, -2, Gravity.CENTER)) + gestures.setOnClickListener { + if (!released) { controls.tap(now()); updateVisibility() } + } + gestures.setOnTouchListener { _, event -> detector.onTouchEvent(event); true } + seek.setOnTouchListener { _, event -> + when { + released -> true + event.actionMasked == MotionEvent.ACTION_DOWN -> { + suppressCancelledTouch = false + false + } + suppressCancelledTouch -> true + event.actionMasked == MotionEvent.ACTION_CANCEL -> { + suppressCancelledTouch = true + cancelActiveScrub(); updateVisibility(); true + } + else -> false + } + } + seek.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onStartTrackingTouch(bar: SeekBar) { + if (released) return + dragging = true + controls.seeking(true, now()); updateVisibility() + } + override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) { + if (released || !fromUser || !canSeek()) return + val target = PlayerSeekMath.fromProgress(state.durationMs, progress) + updateClock(target) + if (!dragging) { + // Keyboard/accessibility changes never send onStopTrackingTouch. + controls.interact(now()) + callbacks.onSeekTo(target) + updateVisibility() + } + } + override fun onStopTrackingTouch(bar: SeekBar) { + if (released || !dragging) return + dragging = false + controls.seeking(false, now()) + if (cancellingDrag) return + if (canSeek()) { + callbacks.onSeekTo(PlayerSeekMath.fromProgress(state.durationMs, bar.progress)) + } + updateVisibility() + } + }) + updateVisibility() + } + + fun render(state: PlayerOverlayState) { + if (released) return + this.state = state + controls.playback(state.playing, state.loading, state.error, state.pip, now()) + title.text = state.title.ifBlank { "ReelBox" } + play.setImageDrawable(PlayerIcon(if (state.playing) "pause" else "play", intrinsicSize = dp(28))) + play.contentDescription = if (state.playing) "Pause" else "Play" + play.isEnabled = !state.error + syncSeekEnabled() + rewind.isEnabled = state.seekable && !state.error; forward.isEnabled = rewind.isEnabled + rewind.alpha = if (rewind.isEnabled) 1f else .4f + forward.alpha = rewind.alpha + speed.text = "${state.speed.toString().removeSuffix(".0")}×" + if (!dragging) { + seek.progress = if (state.durationMs > 0) + (state.positionMs.toDouble() / state.durationMs * 10000).toInt().coerceIn(0, 10000) else 0 + updateClock(state.positionMs) + } + updateVisibility() + } + + fun setMenuOpen(open: Boolean) { + if (!released) { controls.menu(open, now()); updateVisibility() } + } + + fun setForeground(foreground: Boolean) { + if (!released) { controls.foreground(foreground, now()); updateVisibility() } + } + + /** Inset controls, not scrims: their backgrounds must reach every edge. */ + fun setSafeInsets(left: Int, top: Int, right: Int, bottom: Int) { + if (released) return + if (safeInsets == android.graphics.Rect(left, top, right, bottom)) return + safeInsets.set(left, top, right, bottom) + header.setPadding(left, top, right, 0) + header.layoutParams = (header.layoutParams as LinearLayout.LayoutParams).apply { height = dp(48) + top } + this.bottom.setPadding(left, 0, right, bottom) + center.setPadding(left, 0, right, 0) + errorScroll.layoutParams = (errorScroll.layoutParams as LayoutParams).apply { + leftMargin = dp(20) + left; rightMargin = dp(20) + right + topMargin = dp(56) + top; bottomMargin = dp(8) + bottom + } + unlock.layoutParams = (unlock.layoutParams as LayoutParams).apply { rightMargin = dp(16) + right } + updateVisibility() + } + + fun release() { + if (released) return + released = true + controls.release() + scheduledDeadline = null + handler.removeCallbacksAndMessages(null) + // GestureDetector owns an internal Handler; CANCEL clears its pending taps. + val cancel = MotionEvent.obtain(now(), now(), MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + detector.onTouchEvent(cancel) + cancel.recycle() + gestures.setOnTouchListener(null) + gestures.setOnClickListener(null) + seek.setOnSeekBarChangeListener(null) + // Freeze the last frame until the activity detaches; release must not + // flash a new chrome state or let a pending confirmed tap change it. + } + + private fun updateClock(position: Long) { + elapsed.text = playerTime(position) + elapsed.contentDescription = "Elapsed ${elapsed.text}" + remaining.text = if (state.durationMs > 0) "−${playerTime((state.durationMs - position).coerceAtLeast(0))}" else "—:—" + remaining.contentDescription = if (state.durationMs > 0) "Remaining ${remaining.text}" else "Remaining time unknown" + } + + private fun updateVisibility() { + if (released) return + syncSeekEnabled() + gestures.visibility = if (state.pip) View.GONE else View.VISIBLE + importantForAccessibility = if (state.pip) View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + chrome.visibility = if (controls.chromeVisible && !state.pip) View.VISIBLE else View.GONE + unlock.visibility = if (controls.unlockVisible && !state.pip) View.VISIBLE else View.GONE + loading.visibility = if (state.loading && !state.error && !state.pip && !controls.locked) View.VISIBLE else View.GONE + errorScroll.visibility = if (state.error && !state.pip) View.VISIBLE else View.GONE + errorPanel.visibility = errorScroll.visibility + transport.visibility = if (state.error) View.GONE else View.VISIBLE + bottom.visibility = if (state.error) View.GONE else View.VISIBLE + if (state.pip || controls.locked || state.error) feedback.visibility = View.GONE + (loading.layoutParams as LayoutParams).apply { + gravity = if (controls.chromeVisible) Gravity.TOP or Gravity.RIGHT else Gravity.CENTER + topMargin = if (controls.chromeVisible) dp(12) + safeInsets.top else 0 + rightMargin = if (controls.chromeVisible) dp(60) + safeInsets.right else 0 + loading.layoutParams = this + } + if (scheduledDeadline != controls.deadlineMs) { + handler.removeCallbacks(hide) + scheduledDeadline = controls.deadlineMs + scheduledDeadline?.let { handler.postDelayed(hide, (it - now()).coerceAtLeast(0)) } + } + } + + private fun canSeek() = state.seekable && state.durationMs > 0 && !state.error && !state.pip && !controls.locked + + private fun syncSeekEnabled() { + if (!canSeek()) cancelActiveScrub() + seek.isEnabled = canSeek() + } + + private fun cancelActiveScrub() { + if (!dragging || cancellingDrag) return + // Parent touch targets can outlive a disabled/hidden SeekBar. Consume + // the rest of this finger stream even if playback recovers before UP; + // only a fresh DOWN may rearm touch seeking. Non-touch input is separate. + suppressCancelledTouch = true + cancellingDrag = true + val cancel = MotionEvent.obtain(now(), now(), MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + try { + // AbsSeekBar must see CANCEL while still enabled to clear its own + // tracking state. Its onStop callback is cancellation, never a seek. + seek.onTouchEvent(cancel) + } finally { + cancel.recycle() + seek.isPressed = false + dragging = false + controls.seeking(false, now()) + cancellingDrag = false + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Resolve responsive sizes before children measure, including inset-only + // changes where onSizeChanged would not run (rotation/PiP transitions). + val w = MeasureSpec.getSize(widthMeasureSpec) + val h = MeasureSpec.getSize(heightMeasureSpec) + val size = if (h - safeInsets.top - safeInsets.bottom < dp(240)) dp(48) else dp(64) + val playParams = play.layoutParams as LinearLayout.LayoutParams + if (playParams.width != size || playParams.height != size) { + play.layoutParams = playParams.apply { width = size; height = size } + } + val errorWidth = (w - safeInsets.left - safeInsets.right - dp(40)).coerceAtMost(dp(440)).coerceAtLeast(0) + val errorParams = errorScroll.layoutParams as LayoutParams + if (errorParams.width != errorWidth) errorScroll.layoutParams = errorParams.apply { width = errorWidth } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + private fun icon(kind: String, description: String, viewTag: String, action: () -> Unit) = ImageView(context).apply { + tag = viewTag + contentDescription = description + setImageDrawable(PlayerIcon(kind, intrinsicSize = dp(24))) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(dp(12), dp(12), dp(12), dp(12)) + background = RippleDrawable(ColorStateList.valueOf(0x35FFFFFF), null, playerSurface(Color.WHITE, dp(24).toFloat())) + isFocusable = true + setOnClickListener { + if (!released && !state.pip && (!controls.locked || viewTag == "player-unlock")) { + controls.interact(now()); action(); if (!released) updateVisibility() + } + } + } + + private fun action(text: String, viewTag: String, subtle: Boolean = false, action: () -> Unit) = label(if (subtle) 12f else 14f).apply { + tag = viewTag; this.text = text; contentDescription = text + gravity = Gravity.CENTER; maxLines = 2 + setPadding(dp(8), dp(4), dp(8), dp(4)) + setTextColor(if (subtle) 0xFFBDBDBD.toInt() else Color.WHITE) + if (!subtle) background = playerSurface(0xFF3A3A3A.toInt(), dp(12).toFloat()) + isFocusable = true + setOnClickListener { + if (!released && !state.pip) { + controls.interact(now()); action(); if (!released) updateVisibility() + } + } + } + private fun label(size: Float) = TextView(context).apply { + textSize = size; setTextColor(Color.WHITE) + typeface = Typeface.create("sans-serif", Typeface.NORMAL) + includeFontPadding = false + } + private fun scrim(top: Boolean) = GradientDrawable( + if (top) GradientDrawable.Orientation.TOP_BOTTOM else GradientDrawable.Orientation.BOTTOM_TOP, + intArrayOf(0xC9000000.toInt(), 0x00000000), + ) + private fun dp(value: Int) = (value * resources.displayMetrics.density + .5f).toInt() + private fun now() = SystemClock.uptimeMillis() + + companion object { private val CORAL = Color.rgb(255, 130, 118) } +} + +private fun playerTime(ms: Long): String { + val seconds = ms.coerceAtLeast(0) / 1000 + return if (seconds >= 3600) String.format(Locale.ROOT, "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60) + else String.format(Locale.ROOT, "%d:%02d", seconds / 60, seconds % 60) +} + +internal fun playerSurface(color: Int, radius: Float) = GradientDrawable().apply { + setColor(color); cornerRadius = radius +} + +/** One consistent 24-unit, rounded 1.7-unit stroke family; no font glyph icons. */ +internal class PlayerIcon( + private val kind: String, + private val tint: Int = Color.WHITE, + private val intrinsicSize: Int = 24, +) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = tint; style = Paint.Style.STROKE; strokeWidth = 1.7f + strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND + } + override fun draw(canvas: Canvas) { + canvas.save() + canvas.translate(bounds.left.toFloat(), bounds.top.toFloat()) + canvas.scale(bounds.width() / 24f, bounds.height() / 24f) + fun line(vararg coordinates: Float) { + val path = Path(); path.moveTo(coordinates[0], coordinates[1]) + for (i in 2 until coordinates.size step 2) path.lineTo(coordinates[i], coordinates[i + 1]) + canvas.drawPath(path, paint) + } + when (kind) { + "play" -> line(9f, 5f, 19f, 12f, 9f, 19f, 9f, 5f) + "pause" -> { line(8f, 5f, 8f, 19f); line(16f, 5f, 16f, 19f) } + "back" -> { line(14f, 5f, 7f, 12f, 14f, 19f); line(7f, 12f, 21f, 12f) } + "external" -> { + line(14f, 4f, 21f, 4f, 21f, 11f); line(21f, 4f, 11f, 14f) + line(10f, 5f, 4f, 5f, 4f, 20f, 19f, 20f, 19f, 14f) + } + "pip" -> { canvas.drawRoundRect(3f, 5f, 21f, 19f, 1f, 1f, paint); canvas.drawRect(12f, 11f, 18f, 16f, paint) } + "lock", "unlock" -> { + canvas.drawRoundRect(5f, 10f, 19f, 21f, 2f, 2f, paint) + if (kind == "lock") canvas.drawArc(8f, 2f, 16f, 13f, 180f, 180f, false, paint) + else canvas.drawArc(8f, 2f, 18f, 13f, 180f, 150f, false, paint) + line(12f, 14f, 12f, 17f) + } + "settings" -> { + line(4f, 7f, 20f, 7f); line(4f, 17f, 20f, 17f) + canvas.drawCircle(9f, 7f, 3f, Paint(paint).apply { style = Paint.Style.FILL; color = 0xFF242424.toInt() }) + canvas.drawCircle(9f, 7f, 3f, paint) + canvas.drawCircle(15f, 17f, 3f, Paint(paint).apply { style = Paint.Style.FILL; color = 0xFF242424.toInt() }) + canvas.drawCircle(15f, 17f, 3f, paint) + } + "rewind", "forward" -> { + canvas.save() + if (kind == "forward") { canvas.translate(24f, 0f); canvas.scale(-1f, 1f) } + canvas.drawArc(4f, 4f, 21f, 21f, -105f, 285f, false, paint) + line(4f, 3f, 4f, 9f, 10f, 9f) + canvas.restore() + // Numerals are paths, not device-dependent emoji/icon glyphs. + line(9f, 12f, 10f, 11f, 10f, 17f) + canvas.drawRoundRect(13f, 11f, 17f, 17f, 1.5f, 1.5f, paint) + } + "check" -> line(5f, 12f, 10f, 17f, 20f, 7f) + "close" -> { line(6f, 6f, 18f, 18f); line(18f, 6f, 6f, 18f) } + } + canvas.restore() + } + override fun getIntrinsicWidth() = intrinsicSize + override fun getIntrinsicHeight() = intrinsicSize + override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() } + override fun setColorFilter(filter: ColorFilter?) { paint.colorFilter = filter; invalidateSelf() } + @Suppress("OVERRIDE_DEPRECATION") override fun getOpacity() = PixelFormat.TRANSLUCENT +} + +/** Bottom-anchored native sheet, scrollable at small landscape heights/font sizes. */ +internal fun playerChoiceSheet( + context: Context, title: String, values: List, selected: Int, + dismissed: () -> Unit, pick: (Int) -> Unit, +): Dialog { + fun dp(value: Int) = (value * context.resources.displayMetrics.density + .5f).toInt() + val dialog = Dialog(context) + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + val panel = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(12), dp(8), dp(12), dp(12)) + background = playerSurface(0xFF242424.toInt(), dp(20).toFloat()) + } + val heading = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL } + heading.addView(TextView(context).apply { + text = title; textSize = 18f; setTextColor(Color.WHITE) + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + setPadding(dp(12), 0, dp(8), 0) + }, LinearLayout.LayoutParams(0, -2, 1f)) + heading.addView(ImageView(context).apply { + contentDescription = "Close settings" + setImageDrawable(PlayerIcon("close", intrinsicSize = dp(24))); setPadding(dp(12), dp(12), dp(12), dp(12)) + isFocusable = true; setOnClickListener { dialog.dismiss() } + }, LinearLayout.LayoutParams(dp(48), dp(48))) + panel.addView(heading, LinearLayout.LayoutParams(-1, -2)) + val choices = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + values.forEachIndexed { index, value -> + val checked = index == selected + val row = LinearLayout(context).apply { + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(56) + setPadding(dp(12), dp(8), dp(12), dp(8)) + background = playerSurface(if (checked) 0xFF333333.toInt() else 0xFF242424.toInt(), dp(12).toFloat()) + contentDescription = "$value${if (checked) ", selected" else ""}" + isSelected = checked; isFocusable = true + setOnClickListener { dialog.dismiss(); pick(index) } + } + row.addView(TextView(context).apply { + text = value; textSize = 15f; setTextColor(Color.WHITE) + setPadding(0, 0, dp(12), 0) + }, LinearLayout.LayoutParams(0, -2, 1f)) + row.addView(ImageView(context).apply { + if (checked) setImageDrawable(PlayerIcon("check", Color.rgb(255, 130, 118))) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(24), dp(24))) + choices.addView(row, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(4) }) + } + panel.addView(ScrollView(context).apply { addView(choices) }, LinearLayout.LayoutParams(-1, 0, 1f)) + dialog.setContentView(panel) + dialog.setOnDismissListener { dismissed() } + dialog.window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setDimAmount(.55f) + setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL) + val metrics = context.resources.displayMetrics + val contentHeight = dp(76 + values.size * 60) + setLayout((metrics.widthPixels - dp(24)).coerceAtMost(dp(560)), + contentHeight.coerceAtMost((metrics.heightPixels * .82f).toInt())) + } + dialog.show() + return dialog +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/ReelboxCast.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/ReelboxCast.kt new file mode 100644 index 0000000..423e161 --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/ReelboxCast.kt @@ -0,0 +1,173 @@ +package org.reelbox.reelbox + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.mediarouter.app.MediaRouteButton +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaLoadRequestData +import com.google.android.gms.cast.MediaMetadata +import com.google.android.gms.cast.MediaSeekOptions +import com.google.android.gms.cast.MediaTrack +import com.google.android.gms.cast.framework.CastButtonFactory +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.framework.media.CastMediaOptions +import com.google.android.gms.cast.framework.media.RemoteMediaClient +import com.google.android.gms.common.api.PendingResult +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.TimeUnit + +/** Default Media Receiver: no server credentials, DRM keys, or custom receiver app. */ +class ReelboxCastOptions : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions = CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setCastMediaOptions(CastMediaOptions.Builder().setNotificationOptions(null).setMediaSessionEnabled(false).build()) + .setEnableReconnectionService(false) + .build() + override fun getAdditionalSessionProviders(context: Context): List? = null +} + +/** Support-fragment host kept separate from the Flutter activity/theme. */ +class CastPickerActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + title = "Cast video" + val button = MediaRouteButton(this) + button.contentDescription = "Choose a Cast receiver" + setContentView(button, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + try { + CastButtonFactory.setUpMediaRouteButton(this, button) + if (savedInstanceState == null) button.post { button.showDialog() } + } catch (_: Exception) { + android.widget.Toast.makeText(this, "Google Cast is unavailable on this device.", android.widget.Toast.LENGTH_LONG).show() + finish() + } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + override fun onSupportNavigateUp(): Boolean { finish(); return true } +} + +class ReelboxCast(private val activity: MainActivity, private val channel: MethodChannel) { + private var context: CastContext? = null + private val handler = Handler(Looper.getMainLooper()) + private var disposed = false + private val poll = object : Runnable { + override fun run() { + if (disposed) return + emit() + handler.postDelayed(this, 1000) + } + } + private val listener = object : SessionManagerListener { + override fun onSessionStarting(session: CastSession) = emit() + override fun onSessionStarted(session: CastSession, id: String) = emit() + override fun onSessionStartFailed(session: CastSession, error: Int) = emit() + override fun onSessionEnding(session: CastSession) = emit() + override fun onSessionEnded(session: CastSession, error: Int) = emit() + override fun onSessionResuming(session: CastSession, id: String) = emit() + override fun onSessionResumed(session: CastSession, suspended: Boolean) = emit() + override fun onSessionResumeFailed(session: CastSession, error: Int) = emit() + override fun onSessionSuspended(session: CastSession, reason: Int) = emit() + } + + @Suppress("DEPRECATION") + private fun initialize(): CastContext { + check(!disposed) + return context ?: CastContext.getSharedInstance(activity).also { + context = it + it.sessionManager.addSessionManagerListener(listener, CastSession::class.java) + handler.post(poll) + } + } + + fun state(): Map { + val session = context?.sessionManager?.currentCastSession + val connected = session?.isConnected == true + val client = if (connected) session?.remoteMediaClient else null + return mapOf( + "connected" to connected, + "playing" to (client?.isPlaying == true), + "positionMs" to (client?.approximateStreamPosition ?: 0L), + "durationMs" to (client?.streamDuration ?: 0L), + "deviceName" to (session?.castDevice?.friendlyName ?: "") + ) + } + + private fun emit() { if (!disposed) channel.invokeMethod("castChanged", state()) } + + fun showPicker(): Boolean { + initialize() + activity.startActivity(Intent(activity, CastPickerActivity::class.java)) + return true + } + + fun getState(): Map { initialize(); return state() } + + private fun remote(): RemoteMediaClient = initialize().sessionManager.currentCastSession + ?.takeIf { it.isConnected }?.remoteMediaClient + ?: throw IllegalStateException("No Cast receiver is connected.") + + fun load(args: Map<*, *>, result: MethodChannel.Result) { + val url = args["url"] as? String ?: "" + require(NativeUrlPolicy.allowsRemote(url)) { "Unsupported Cast URL." } + val subtitle = (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() } + require(subtitle == null || NativeUrlPolicy.allowsRemote(subtitle)) { "Unsupported subtitle URL." } + val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE).apply { + putString(MediaMetadata.KEY_TITLE, (args["title"] as? String).orEmpty().take(512)) + } + val contentType = (args["contentType"] as? String)?.takeIf { it.isNotBlank() } ?: when { + android.net.Uri.parse(url).path.orEmpty().endsWith(".m3u8", true) -> "application/x-mpegURL" + android.net.Uri.parse(url).path.orEmpty().endsWith(".mpd", true) -> "application/dash+xml" + else -> "video/mp4" + } + val info = MediaInfo.Builder(url).setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(contentType).setMetadata(metadata) + if (subtitle != null) info.setMediaTracks(listOf(MediaTrack.Builder(1, MediaTrack.TYPE_TEXT) + .setContentId(subtitle).setContentType("text/vtt").setSubtype(MediaTrack.SUBTYPE_SUBTITLES) + .setName("Subtitles").build())) + val request = MediaLoadRequestData.Builder().setMediaInfo(info.build()).setAutoplay(true) + .setCurrentTime((args["positionMs"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0) + if (subtitle != null) request.setActiveTrackIds(longArrayOf(1)) + complete(remote().load(request.build()), result) + } + + fun command(args: Map<*, *>, result: MethodChannel.Result) { + val client = remote() + val request = when (args["action"] as? String) { + "play" -> client.play() + "pause" -> client.pause() + "stop" -> client.stop() + "seek" -> client.seek(MediaSeekOptions.Builder() + .setPosition((args["positionMs"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0).build()) + "volume" -> client.setStreamVolume((args["value"] as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0) ?: 1.0) + else -> throw IllegalArgumentException("Unknown Cast command.") + } + complete(request, result) + } + + private fun complete(request: PendingResult, result: MethodChannel.Result) { + request.setResultCallback({ response -> + if (response.status.isSuccess) result.success(true) + else result.error("CAST_RECEIVER", "The Cast receiver rejected or timed out this operation.", response.status.statusCode) + emit() + }, 20, TimeUnit.SECONDS) + } + + fun disconnect() { initialize().sessionManager.endCurrentSession(true); emit() } + fun dispose() { + disposed = true + handler.removeCallbacksAndMessages(null) + context?.sessionManager?.removeSessionManagerListener(listener, CastSession::class.java) + context = null + } +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/RestrictedResourceFetcher.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/RestrictedResourceFetcher.kt new file mode 100644 index 0000000..20a824f --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/RestrictedResourceFetcher.kt @@ -0,0 +1,116 @@ +package org.reelbox.reelbox + +import java.io.ByteArrayInputStream +import java.io.FilterInputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URI + +/** Credential-free HTTP transport: the WebView must never own HTTP redirects. */ +internal class RestrictedResourceFetcher( + private val openConnection: (java.net.URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection }, +) { + data class Response( + val statusCode: Int, + val body: InputStream, + val mimeType: String = "text/plain", + val encoding: String? = "UTF-8", + val reasonPhrase: String = "Blocked", + val headers: Map = emptyMap(), + val effectiveUrl: String = "", + ) + + fun fetch( + url: String, + method: String = "GET", + requestHeaders: Map = emptyMap(), + secureContext: Boolean = false, + ): Response { + if (method != "GET" && method != "HEAD") return empty(405) + if (!allowsResource(url)) return empty(403) + var target = URI(url).normalize() + var requireTls = secureContext || target.scheme.equals("https", true) + var forwardContext = true + var active: HttpURLConnection? = null + try { + repeat(6) { hop -> + if (!allowsResource(target.toString()) || + (requireTls && !target.scheme.equals("https", true))) return empty(403) + requireTls = requireTls || target.scheme.equals("https", true) + val connection = openConnection(target.toURL()) + active = connection + connection.connectTimeout = 10_000 + connection.readTimeout = 15_000 + connection.allowUserInteraction = false + connection.instanceFollowRedirects = false + connection.requestMethod = method + connection.useCaches = false + // A small public-resource allowlist, not arbitrary headers or a cookie jar. + for ((name, value) in requestHeaders) { + val lower = name.lowercase(java.util.Locale.ROOT) + val allowedHeader = lower in setOf("accept", "accept-language", "user-agent", "range", "if-range") || + (forwardContext && lower in setOf("origin", "referer")) + if (allowedHeader && value.none { it == '\r' || it == '\n' || it == '\u0000' }) { + connection.setRequestProperty(name, value) + } + } + // Keep payload bytes and Content-Length/Range/Encoding consistent; no transparent gzip. + connection.setRequestProperty("Accept-Encoding", "identity") + val status = connection.responseCode + if (status in listOf(301, 302, 303, 307, 308)) { + val location = connection.getHeaderField("Location") + connection.disconnect() + active = null + if (location.isNullOrBlank()) return empty(502) + if (hop == 5) return empty(508) + val next = target.resolve(location).normalize() + fun port(uri: URI) = if (uri.port != -1) uri.port else if (uri.scheme.equals("https", true)) 443 else 80 + if (!target.scheme.equals(next.scheme, true) || !target.host.equals(next.host, true) || port(target) != port(next)) { + forwardContext = false // Never restore a stripped context on a later hop back. + } + target = next + } else { + // Android rejects all 3xx WebResourceResponse statuses, including 304. + if (status !in 100..299 && status !in 400..599) return empty(502) + val stream = if (method == "HEAD") null else if (status >= 400) connection.errorStream else connection.inputStream + val contentType = connection.contentType.orEmpty().split(';') + val mime = contentType.first().trim().ifEmpty { "application/octet-stream" } + val charset = contentType.drop(1).firstOrNull { it.trim().startsWith("charset=", true) } + ?.substringAfter('=')?.trim()?.trim('"', '\'') + val hopHeaders = connection.getHeaderField("Connection").orEmpty().split(',') + .map { it.trim().lowercase(java.util.Locale.ROOT) }.toSet() + val excluded = hopHeaders + setOf("connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", "set-cookie", "set-cookie2") + val headers = connection.headerFields.filterKeys { it != null && it.lowercase(java.util.Locale.ROOT) !in excluded } + .mapValues { (_, values) -> values.joinToString(", ") } + val body = object : FilterInputStream(stream ?: ByteArrayInputStream(ByteArray(0))) { + override fun close() { try { super.close() } finally { connection.disconnect() } } + } + val reason = connection.responseMessage.orEmpty() + .takeIf { it.isNotBlank() && it.all { char -> char.code in 32..126 } } ?: "HTTP response" + active = null // Body owns the connection until WebView closes it. + return Response(status, body, mime, charset, reason, headers, target.toString()) + } + } + } catch (_: Exception) { + return empty(502) + } finally { active?.disconnect() } + return empty(508) + } + + private fun allowsResource(url: String): Boolean = try { + if (!NativeUrlPolicy.allowsRemote(url)) false else { + val uri = URI(url) + var route = uri.rawPath.orEmpty() + "?" + uri.rawQuery.orEmpty() + repeat(3) { route = java.net.URLDecoder.decode(route, "UTF-8") } + route = java.text.Normalizer.normalize(route, java.text.Normalizer.Form.NFKC) + .lowercase(java.util.Locale.ROOT).replace(Regex("[\\p{Cf}]"), "") + // Removed catalog families are not embed resources. Keep private/episode playlists. + !Regex("(^|[/_.?&=\\-])(live|channels?|live[-_]?channels?|public[-_]?(play)?lists?)([/_.?&=\\-]|$)") + .containsMatchIn(route) && + !Regex("[?&]action=(showlist|show-list_fav|playlist-items)([&#]|$)").containsMatchIn(route) + } + } catch (_: Exception) { false } + + private fun empty(status: Int) = Response(status, ByteArrayInputStream(ByteArray(0))) +} diff --git a/android/app/src/main/kotlin/org/reelbox/reelbox/VlcPlayerActivity.kt b/android/app/src/main/kotlin/org/reelbox/reelbox/VlcPlayerActivity.kt new file mode 100644 index 0000000..328a34f --- /dev/null +++ b/android/app/src/main/kotlin/org/reelbox/reelbox/VlcPlayerActivity.kt @@ -0,0 +1,643 @@ +package org.reelbox.reelbox + +import android.app.Activity +import android.app.Dialog +import android.app.PictureInPictureParams +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.Color +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Rational +import android.view.View +import android.view.WindowInsets +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.Toast +import org.videolan.libvlc.LibVLC +import org.videolan.libvlc.Media +import org.videolan.libvlc.MediaPlayer +import org.videolan.libvlc.interfaces.IMedia +import org.videolan.libvlc.util.VLCVideoLayout +import java.io.File +import java.util.concurrent.Executors + +/** Embedded LGPL LibVLC; never launches proprietary KMPlayer or rewrites source + * hosts. APIs verified against libvlc-all 3.7.7 sources and classes.jar on Maven + * Central. LibVLC owns asynchronous network I/O and normal TLS/auth validation. + * No network fetch, trust-manager override or media proxy exists in this view. + * + * Background policy is deliberately pause, not an unowned background decoder. + * PiP retains the video/decoder; closing the view releases it on a worker. + */ +@Suppress("DEPRECATION") // API 24 compatibility branches alongside modern APIs. +class VlcPlayerActivity : Activity() { + private val main = Handler(Looper.getMainLooper()) + private val teardown = Executors.newSingleThreadExecutor() + private var vlc: LibVLC? = null + private var player: MediaPlayer? = null + private lateinit var launch: NativePlayerLaunch + private lateinit var surface: VLCVideoLayout + private lateinit var root: FrameLayout + private lateinit var overlay: PlayerOverlayView + private var requestId = "" + private var positionMs = 0L + private var durationMs = 0L + private var opened = false + private var completed = false + private var desiredPlaying = true + private var foreground = false + private var closed = false + private var loading = false + private var releasing = false + private var reloadRequested = false + private var error = false + private var pendingSeek: Long? = null + private var seekWaitTicks = 0 + private var speed = 1f + private var fit = "contain" + private var hardware = true + private var subtitles = true + private var audioTrackId: Int? = null + private var subtitleTrackId: Int? = null + private var originalSystemUi = 0 + private var originalOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + private lateinit var audio: AudioManager + private var focusRequest: AudioFocusRequest? = null + private var hasFocus = false + private var resumeAfterFocus = false + private var receiverRegistered = false + private var dialog: Dialog? = null + private val menuSession = PlayerMenuSession() + + private val focusListener = AudioManager.OnAudioFocusChangeListener { change -> + main.post { + if (!closed) when (change) { + AudioManager.AUDIOFOCUS_GAIN -> { + hasFocus = true + player?.setVolume(100) + if (resumeAfterFocus && (foreground || inPip()) && !error) { + resumeAfterFocus = false + desiredPlaying = true + player?.play() + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> player?.setVolume(25) + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS -> { + resumeAfterFocus = change == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT && desiredPlaying + hasFocus = false + if (change == AudioManager.AUDIOFOCUS_LOSS) desiredPlaying = false + player?.pause() + render() + } + } + } + } + private val noisy = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) pauseByUser() + } + } + private val tick = object : Runnable { + override fun run() { + if (closed) return + if (foreground || inPip()) { + applyPendingSeek() + snapshot() + render() + } + main.postDelayed(this, 500) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + originalSystemUi = window.decorView.systemUiVisibility + originalOrientation = requestedOrientation + requestId = intent.getStringExtra(NativePlayerRequests.REQUEST_ID).orEmpty() + // Even a failed/recreated launch has an identified cancellation result. + setResult(RESULT_CANCELED, Intent().putExtra(NativePlayerRequests.REQUEST_ID, requestId)) + try { + val extras = intent.extras + val args = extras?.keySet()?.associateWith { extras.get(it) }.orEmpty() + launch = NativePlayerLaunch.from(args, ownedRoots(this)) + } catch (_: Exception) { + finish() + return + } + positionMs = savedInstanceState?.getLong("positionMs") ?: launch.positionMs + durationMs = savedInstanceState?.getLong("durationMs") ?: 0 + opened = savedInstanceState?.getBoolean("opened") ?: false + desiredPlaying = savedInstanceState?.getBoolean("playing") ?: launch.autoplay + speed = savedInstanceState?.getFloat("speed") ?: launch.speed + fit = savedInstanceState?.getString("fit") ?: launch.videoFit + hardware = savedInstanceState?.getBoolean("hardware") ?: launch.hardwareAcceleration + subtitles = savedInstanceState?.getBoolean("subtitles") ?: launch.subtitleEnabled + if (savedInstanceState?.containsKey("audioTrack") == true) audioTrackId = savedInstanceState.getInt("audioTrack") + if (savedInstanceState?.containsKey("subtitleTrack") == true) subtitleTrackId = savedInstanceState.getInt("subtitleTrack") + audio = getSystemService(AUDIO_SERVICE) as AudioManager + volumeControlStream = AudioManager.STREAM_MUSIC + if (Build.VERSION.SDK_INT >= 26) { + focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build()) + .setOnAudioFocusChangeListener(focusListener, main).build() + } + if (Build.VERSION.SDK_INT >= 33) registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), RECEIVER_NOT_EXPORTED) + else registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) + receiverRegistered = true + requestedOrientation = when (launch.orientation) { + "landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + "portrait" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + buildUi() + configureWindow() + publish() + main.post(tick) + load() + } + + private fun buildUi() { + root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + surface = VLCVideoLayout(this) + root.addView(surface, FrameLayout.LayoutParams(-1, -1)) + overlay = PlayerOverlayView(this, object : PlayerOverlayView.Callbacks { + override fun onClose() { finish() } + override fun onTogglePlayback() { togglePlay() } + override fun onSeekRelative(deltaMs: Long) { seekTo(PlayerSeekMath.relative(positionMs, deltaMs)) } + override fun onSeekTo(positionMs: Long) { seekTo(positionMs) } + override fun onSettings() { showSettings() } + override fun onExternal() { external() } + override fun onRetry(software: Boolean) { retry(software) } + override fun onPictureInPicture() { + if (!enterPip()) toast("Picture-in-picture is unavailable") + } + }) + root.addView(overlay, FrameLayout.LayoutParams(-1, -1)) + setContentView(root) + root.setOnApplyWindowInsetsListener { _, insets -> + // Insets never pad the video. Only the sibling chrome avoids bars. + if (inPip()) overlay.setSafeInsets(0, 0, 0, 0) + else if (Build.VERSION.SDK_INT >= 30) { + val bars = insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()) + overlay.setSafeInsets(bars.left, bars.top, bars.right, bars.bottom) + } else overlay.setSafeInsets(insets.systemWindowInsetLeft, insets.systemWindowInsetTop, + insets.systemWindowInsetRight, insets.systemWindowInsetBottom) + insets + } + render() + } + + @Suppress("DEPRECATION") + private fun configureWindow() { + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + if (Build.VERSION.SDK_INT >= 28) window.attributes = window.attributes.apply { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + root.requestApplyInsets() + } + + private fun load() { + if (closed || !::launch.isInitialized) return + if (releasing) { reloadRequested = true; return } + error = false + loading = true + completed = false + pendingSeek = positionMs.takeIf { it > 0 } + seekWaitTicks = 0 + render() + try { + val engine = LibVLC(applicationContext) + vlc = engine + val current = MediaPlayer(engine) + player = current + current.attachViews(surface, null, true, false) + applyFit() + current.setVideoTitleDisplay(MediaPlayer.Position.Disable, 0) + current.setEventListener { event -> + if (!closed && player === current) handleEvent(current, event) + } + val media = Media(engine, mediaUri(launch.url)) + try { + media.setHWDecoderEnabled(hardware, false) + launch.subtitleUrl?.let { media.addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, mediaUri(it).toString())) } + current.media = media + } finally { media.release() } + current.rate = speed + // A paused launch still prerolls to obtain duration/tracks. Mute until + // its first Playing callback has paused it, avoiding an audio burst. + if (!desiredPlaying || !foreground) current.setVolume(0) + if (desiredPlaying && foreground && !requestFocus()) desiredPlaying = false + current.play() + render() + } catch (_: Exception) { fail() } + catch (_: LinkageError) { fail() } + } + + private fun handleEvent(current: MediaPlayer, event: MediaPlayer.Event) { + if (error) { + if (event.type == MediaPlayer.Event.Playing) current.pause() + return // Errors remain visible until an explicit retry creates a new engine. + } + when (event.type) { + MediaPlayer.Event.Opening -> { loading = true } + MediaPlayer.Event.Buffering -> { + loading = event.buffering < 100f + } + MediaPlayer.Event.Playing -> { + opened = true + loading = false + error = false + applyPendingSeek() + current.rate = speed + restoreTracks() + if (!desiredPlaying || (!foreground && !inPip()) || !hasFocus) current.pause() + current.setVolume(100) + snapshot() + } + MediaPlayer.Event.Paused -> { loading = false; snapshot() } + MediaPlayer.Event.TimeChanged -> { + if (pendingSeek == null) positionMs = event.timeChanged.coerceAtLeast(0) + publish() + } + // Length can temporarily collapse during buffering/decoder teardown. + // This activity owns one source, so retain its longest known length. + MediaPlayer.Event.LengthChanged -> { durationMs = maxOf(durationMs, event.lengthChanged); publish() } + MediaPlayer.Event.SeekableChanged -> applyPendingSeek() + MediaPlayer.Event.ESAdded -> restoreTracks() + MediaPlayer.Event.EndReached -> { + // A truncated/ended input is not proof that the episode finished. + // Validate the last observed checkpoint BEFORE replacing it with + // the duration; otherwise an early EOF fabricates 100% + auto-next. + val tolerance = (durationMs / 50L).coerceIn(1L, 2000L) + val atEnd = opened && pendingSeek == null && durationMs > 0 && + positionMs > 0 && positionMs <= durationMs && durationMs - positionMs <= tolerance + if (!atEnd) { + completed = false + desiredPlaying = false + // Stopped decoders may reset their time/length. Preserve the + // last observed checkpoint for explicit retry or handoff. + fail(captureSnapshot = false) + publish() + return + } + completed = true + desiredPlaying = false + if (durationMs > 0) positionMs = durationMs + publish() + finish() + return + } + // LibVLC exposes no reliable network-vs-decoder reason here. Never + // auto-retry this event: software retry requires explicit user choice. + MediaPlayer.Event.EncounteredError -> fail() + } + render() + } + + private fun applyPendingSeek() { + val value = pendingSeek ?: return + val current = player ?: return + if (current.isSeekable) { + val duration = maxOf(0L, durationMs, current.length) + positionMs = if (duration > 0) value.coerceAtMost(duration) else value + pendingSeek = null + current.setTime(positionMs) + } else if (opened && ++seekWaitTicks > 12) { + pendingSeek = null + toast("This stream does not support seeking") + } + } + + private fun snapshot() { + val current = player ?: return + if (!error) { + if (pendingSeek == null && !completed) positionMs = current.time.takeIf { it >= 0 } ?: positionMs + durationMs = maxOf(durationMs, current.length) + } + publish() + } + + // An overrun makes the length uncertain, not the measured checkpoint. + // Keep canonical timing intact for EOF validation and later length metadata. + private fun credibleDuration(): Long = durationMs.coerceAtLeast(0).takeIf { it >= positionMs } ?: 0L + + private fun publish() { + val duration = credibleDuration() + val position = positionMs.coerceAtLeast(0) + NativePlayerRequests.results.update(requestId, position, duration, opened, completed) + setResult(RESULT_OK, Intent().putExtra(NativePlayerRequests.REQUEST_ID, requestId) + .putExtra("positionMs", position).putExtra("durationMs", duration) + .putExtra("opened", opened).putExtra("completed", completed && opened)) + } + + private fun render() { + if (!::overlay.isInitialized || closed) return + val current = player + overlay.render(PlayerOverlayState( + title = launch.title, positionMs = positionMs.coerceAtLeast(0), durationMs = credibleDuration(), + playing = current?.isPlaying == true, loading = loading, error = error, + seekable = current?.isSeekable == true, speed = speed, pip = inPip(), + )) + if (current?.isPlaying == true && (foreground || inPip())) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + private fun togglePlay() { + val current = player ?: return + if (current.isPlaying || desiredPlaying && loading) pauseByUser() + else if (requestFocus()) { + resumeAfterFocus = false + desiredPlaying = true + current.play() + } else toast("Audio is in use by another app") + render() + } + + private fun pauseByUser() { + desiredPlaying = false + resumeAfterFocus = false + player?.pause() + snapshot() + render() + } + + private fun seekTo(value: Long) { + val current = player ?: return + if (!current.isSeekable || error) { toast("This stream is not seekable"); return } + positionMs = value.coerceAtLeast(0).let { if (durationMs > 0) it.coerceAtMost(durationMs) else it } + pendingSeek = null + current.setTime(positionMs) + publish() + render() + } + + private fun fail(captureSnapshot: Boolean = true) { + if (captureSnapshot) snapshot() + error = true + loading = false + player?.pause() + dialog?.dismiss() + render() + } + + private fun retry(software: Boolean) { + if (closed || releasing) return + snapshot() + if (software) hardware = false + desiredPlaying = true + if (player == null && vlc == null) load() + else { reloadRequested = true; releaseEngine() } + } + + private fun external() { + snapshot() + pauseByUser() + try { + val ok = NativeFileIntents(this).openExternal(mapOf( + "url" to launch.url, "title" to launch.title, "positionMs" to positionMs, + "packageName" to launch.externalPackage, + "subtitleUrl" to launch.subtitleUrl.takeIf { subtitles })) + if (!ok) toast("No external video player is installed") + } catch (_: Exception) { toast("This source could not be handed to an external player") } + } + + private fun restoreTracks() { + val current = player ?: return + audioTrackId?.let { id -> if (current.audioTracks?.any { it.id == id } == true) current.setAudioTrack(id) } + if (!subtitles) current.setSpuTrack(-1) + else subtitleTrackId?.let { id -> if (current.spuTracks?.any { it.id == id } == true) current.setSpuTrack(id) } + } + + private fun chooseTrack(subtitle: Boolean) { + val current = player ?: return + val tracks = (if (subtitle) current.spuTracks else current.audioTracks).orEmpty().filter { it.id >= 0 } + val ids = if (subtitle) listOf(-1) + tracks.map { it.id } else tracks.map { it.id } + val names = if (subtitle) listOf("Off") + tracks.map { it.name } else tracks.map { it.name } + if (ids.isEmpty()) { toast("No audio tracks available yet"); return } + val selected = if (subtitle) { if (subtitles) current.spuTrack else -1 } else current.audioTrack + showChoices(if (subtitle) "Subtitles" else "Audio track", names, ids.indexOf(selected)) { index -> + if (player !== current) return@showChoices + val id = ids[index] + val accepted = if (subtitle) current.setSpuTrack(id) else current.setAudioTrack(id) + if (accepted) { + if (subtitle) { subtitles = id != -1; subtitleTrackId = id } + else audioTrackId = id + } else toast("This track is not available") + } + } + + private fun chooseFit() { + val values = listOf("contain", "cover", "fill") + showChoices("Video fit", listOf("Fit (letterbox)", "Cover (crop)", "Stretch"), values.indexOf(fit)) { + fit = values[it]; applyFit() + } + } + + private fun applyFit() { + player?.setVideoScale(when (fit) { + "cover" -> MediaPlayer.ScaleType.SURFACE_FIT_SCREEN + "fill" -> MediaPlayer.ScaleType.SURFACE_FILL + else -> MediaPlayer.ScaleType.SURFACE_BEST_FIT + }) + } + + private fun chooseSpeed() { + val rates = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f) + showChoices("Playback speed", rates.map { "${it}×" }, rates.indexOf(speed)) { + speed = rates[it]; player?.rate = speed; render() + } + } + + private fun showSettings() { + showChoices("Player settings", listOf("Audio track", "Subtitles", "Video fit", "Playback speed"), -1) { + when (it) { + 0 -> chooseTrack(false) + 1 -> chooseTrack(true) + 2 -> chooseFit() + 3 -> chooseSpeed() + } + } + } + + private fun showChoices(title: String, values: List, selected: Int, pick: (Int) -> Unit) { + if (closed || inPip()) return + // Invalidate the old owner before dismiss(): Android delivers onDismiss + // asynchronously, potentially after the replacement sheet has opened. + val owner = menuSession.open() + dialog?.dismiss() + overlay.setMenuOpen(true) + dialog = playerChoiceSheet(this, title, values, selected, + dismissed = { + if (menuSession.close(owner)) { + dialog = null + overlay.setMenuOpen(false) + } + }, + pick = { index -> if (!closed && !inPip()) pick(index) }) + } + + private fun requestFocus(): Boolean { + if (hasFocus) return true + hasFocus = if (Build.VERSION.SDK_INT >= 26) audio.requestAudioFocus(focusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + else audio.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasFocus + } + + private fun abandonFocus() { + if (!::audio.isInitialized) return + if (Build.VERSION.SDK_INT >= 26) focusRequest?.let { audio.abandonAudioFocusRequest(it) } + else audio.abandonAudioFocus(focusListener) + hasFocus = false + } + + private fun inPip() = Build.VERSION.SDK_INT >= 26 && isInPictureInPictureMode + + private fun enterPip(): Boolean { + if (!::launch.isInitialized || !launch.pipEnabled || !opened || error || Build.VERSION.SDK_INT < 26 || + !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false + dialog?.dismiss() + return try { + enterPictureInPictureMode(PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build()) + } catch (_: RuntimeException) { false } + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (desiredPlaying && player?.isPlaying == true) enterPip() + } + + override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(inPip, newConfig) + if (!::overlay.isInitialized) return + if (inPip) dialog?.dismiss() + render() + root.requestApplyInsets() + player?.updateVideoSurfaces() + if (!inPip && !foreground) player?.pause() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (::root.isInitialized) { configureWindow(); player?.updateVideoSurfaces() } + } + + override fun onStart() { + super.onStart() + foreground = true + if (::overlay.isInitialized) overlay.setForeground(true) + if (!closed && ::launch.isInitialized && !error && desiredPlaying && requestFocus()) player?.play() + } + + override fun onStop() { + foreground = false + if (::overlay.isInitialized) overlay.setForeground(false) + if (!inPip()) { + // Do not change desiredPlaying: returning restores the user's state. + player?.pause() + snapshot() + abandonFocus() + } + super.onStop() + } + + override fun onSaveInstanceState(outState: Bundle) { + snapshot() + outState.putLong("positionMs", positionMs) + outState.putLong("durationMs", durationMs) + outState.putBoolean("opened", opened) + outState.putBoolean("playing", desiredPlaying) + outState.putFloat("speed", speed) + outState.putString("fit", fit) + outState.putBoolean("hardware", hardware) + outState.putBoolean("subtitles", subtitles) + audioTrackId?.let { outState.putInt("audioTrack", it) } + subtitleTrackId?.let { outState.putInt("subtitleTrack", it) } + super.onSaveInstanceState(outState) + } + + override fun finish() { + if (!closed) { + snapshot() + publish() + closed = true + desiredPlaying = false + main.removeCallbacks(tick) + dialog?.dismiss() + if (::overlay.isInitialized) overlay.release() + abandonFocus() + releaseEngine() + } + super.finish() + } + + /** View detachment must run on main; native stop/release can wait for network + * shutdown and are serialized off-main. No stale callbacks can touch a retry. */ + private fun releaseEngine() { + val old = player + val engine = vlc + player = null + vlc = null + if (old == null && engine == null) return + old?.setEventListener(null) + old?.pause() // Silence immediately; native stop may wait for network I/O. + old?.detachViews() + releasing = true + teardown.execute { + try { old?.stop() } catch (_: Exception) { } + try { old?.release() } catch (_: Exception) { } + try { engine?.release() } catch (_: Exception) { } + main.post { + releasing = false + if (!closed && reloadRequested) { reloadRequested = false; load() } + } + } + } + + override fun onDestroy() { + snapshot() + closed = true + main.removeCallbacksAndMessages(null) + dialog?.dismiss() + if (::overlay.isInitialized) overlay.release() + if (receiverRegistered) { unregisterReceiver(noisy); receiverRegistered = false } + abandonFocus() + releaseEngine() + teardown.shutdown() + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.decorView.systemUiVisibility = originalSystemUi + // Orientation is activity-scoped, never mutate the Flutter host's window. + if (requestedOrientation != originalOrientation) requestedOrientation = originalOrientation + super.onDestroy() + } + + private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + + companion object { + fun ownedRoots(context: Context): List = listOfNotNull(context.filesDir, context.cacheDir, + context.getExternalFilesDir(null), context.externalCacheDir) + private fun mediaUri(value: String): Uri = if (value.startsWith('/')) Uri.fromFile(File(value)) else Uri.parse(value) + fun intent(context: Context, value: NativePlayerLaunch, id: String): Intent = Intent(context, VlcPlayerActivity::class.java) + .putExtra(NativePlayerRequests.REQUEST_ID, id) + .putExtra("url", value.url).putExtra("title", value.title).putExtra("positionMs", value.positionMs) + .putExtra("autoplay", value.autoplay).putExtra("externalPackage", value.externalPackage) + .putExtra("orientation", value.orientation).putExtra("videoFit", value.videoFit).putExtra("speed", value.speed) + .putExtra("subtitleEnabled", value.subtitleEnabled).putExtra("subtitleUrl", value.subtitleUrl) + .putExtra("hardwareAcceleration", value.hardwareAcceleration).putExtra("pipEnabled", value.pipEnabled) + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_playback.xml b/android/app/src/main/res/drawable/ic_playback.xml new file mode 100644 index 0000000..c43cbbb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_playback.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..e64dd87 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..35cff6c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5d7bece Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..599dc5e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..e3a64db Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/native_styles.xml b/android/app/src/main/res/values/native_styles.xml new file mode 100644 index 0000000..cc1b7ec --- /dev/null +++ b/android/app/src/main/res/values/native_styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/reelbox_file_paths.xml b/android/app/src/main/res/xml/reelbox_file_paths.xml new file mode 100644 index 0000000..ef61793 --- /dev/null +++ b/android/app/src/main/res/xml/reelbox_file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/test/java/org/reelbox/reelbox/NativePlayerTimingAndroidTest.java b/android/app/src/test/java/org/reelbox/reelbox/NativePlayerTimingAndroidTest.java new file mode 100644 index 0000000..82dcd59 --- /dev/null +++ b/android/app/src/test/java/org/reelbox/reelbox/NativePlayerTimingAndroidTest.java @@ -0,0 +1,183 @@ +package org.reelbox.reelbox; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import kotlin.Unit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.GraphicsMode; +import org.robolectric.annotation.LooperMode; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadow.api.Shadow; +import org.videolan.libvlc.MediaPlayer; +import static org.junit.Assert.*; + +/** Drives the actual activity event handler and result gate, without a decoder. + * Event/checkpoint fixtures are explicit; this is not device/network playback. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, manifest = Config.NONE) +@LooperMode(LooperMode.Mode.PAUSED) +@GraphicsMode(GraphicsMode.Mode.LEGACY) +public class NativePlayerTimingAndroidTest { + private static Field field(String name) throws Exception { + Field field = VlcPlayerActivity.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } + private static void set(VlcPlayerActivity activity, String name, Object value) throws Exception { + field(name).set(activity, value); + } + private static Object get(VlcPlayerActivity activity, String name) throws Exception { + return field(name).get(activity); + } + private static void event(VlcPlayerActivity activity, int type, long value) throws Exception { + Constructor constructor = MediaPlayer.Event.class.getDeclaredConstructor(int.class, long.class); + constructor.setAccessible(true); + MediaPlayer.Event event = constructor.newInstance(type, value); + Method handler = VlcPlayerActivity.class.getDeclaredMethod("handleEvent", MediaPlayer.class, MediaPlayer.Event.class); + handler.setAccessible(true); + // These timing/EOF branches use activity state, not a decoder instance. + // Deliberately do not start LibVLC/JNI or invent a decoded frame. + handler.invoke(activity, null, event); + } + private static VlcPlayerActivity checkpoint(long position, long duration, boolean opened) throws Exception { + VlcPlayerActivity activity = Robolectric.buildActivity(VlcPlayerActivity.class).get(); + set(activity, "positionMs", position); + set(activity, "durationMs", duration); + set(activity, "opened", opened); + return activity; + } + private static void close(VlcPlayerActivity activity) throws Exception { + activity.finish(); + ((ExecutorService) get(activity, "teardown")).shutdownNow(); + NativePlayerRequests.INSTANCE.getResults().dispose(); + } + + @Implements(value = MediaPlayer.class, callThroughByDefault = false) + public static class SeekDecoder { + static long lastSeek; + @Implementation protected boolean isSeekable() { return true; } + @Implementation protected long getLength() { return 120_000L; } + @Implementation protected long setTime(long value) { lastSeek = value; return 0; } + } + + @Test + @Config(shadows = SeekDecoder.class, instrumentedPackages = "org.videolan.libvlc") + public void retrySeekKeepsCheckpointWhenDecoderLengthTemporarilyShrinks() throws Exception { + VlcPlayerActivity activity = checkpoint(300_000L, 3_600_000L, true); + MediaPlayer decoder = Shadow.newInstanceOf(MediaPlayer.class); + set(activity, "player", decoder); + set(activity, "pendingSeek", 300_000L); + SeekDecoder.lastSeek = -1; + try { + Method seek = VlcPlayerActivity.class.getDeclaredMethod("applyPendingSeek"); + seek.setAccessible(true); + seek.invoke(activity); + assertEquals("Retry must not resume at a shorter, transient length", 300_000L, SeekDecoder.lastSeek); + assertEquals(300_000L, get(activity, "positionMs")); + } finally { + set(activity, "player", null); // No decoder/JNI was constructed. + close(activity); + } + } + + @Test public void overrunTimeSampleCannotBeClampedIntoSuccessfulCompletion() throws Exception { + VlcPlayerActivity activity = checkpoint(180_000L, 3_600_000L, true); + List> results = new ArrayList<>(); + NativePlayerResultGate gate = NativePlayerRequests.INSTANCE.getResults(); + gate.dispose(); + assertTrue(gate.begin("overrun", 0L, value -> { results.add(value); return Unit.INSTANCE; })); + set(activity, "requestId", "overrun"); + try { + event(activity, MediaPlayer.Event.TimeChanged, 3_600_001L); + event(activity, MediaPlayer.Event.EndReached, 0); + assertFalse("A clamped overrun must not authorize auto-next", activity.isFinishing()); + assertEquals(3_600_001L, get(activity, "positionMs")); + assertEquals(false, get(activity, "completed")); + gate.finish("overrun"); + assertEquals(3_600_001L, results.get(0).get("positionMs")); + assertEquals("Inconsistent duration is unknown, not a fake 100%", 0L, results.get(0).get("durationMs")); + } finally { close(activity); } + } + + @Test public void transientLengthChangesKeepKnownDurationAndActualCheckpoint() throws Exception { + for (long reported : new long[] {120_000L, 0L, -1L}) { + VlcPlayerActivity activity = checkpoint(300_000L, 3_600_000L, true); + try { + event(activity, MediaPlayer.Event.LengthChanged, reported); + assertEquals("Transient length must not replace known episode duration", 3_600_000L, get(activity, "durationMs")); + assertEquals("Length metadata must not move the playback checkpoint", 300_000L, get(activity, "positionMs")); + } finally { close(activity); } + } + } + + @Test public void unknownDurationEndCannotClaimCompletion() throws Exception { + VlcPlayerActivity activity = checkpoint(180_000L, 0L, true); + try { + event(activity, MediaPlayer.Event.EndReached, 0); + assertFalse(activity.isFinishing()); + assertEquals(false, get(activity, "completed")); + assertEquals(180_000L, get(activity, "positionMs")); + } finally { close(activity); } + } + + @Test public void pendingResumeSeekCannotClaimCompletion() throws Exception { + VlcPlayerActivity activity = checkpoint(3_599_500L, 3_600_000L, true); + set(activity, "pendingSeek", 3_599_500L); + try { + event(activity, MediaPlayer.Event.EndReached, 0); + assertFalse(activity.isFinishing()); + assertEquals(false, get(activity, "completed")); + } finally { close(activity); } + } + + @Test public void neverOpenedDecoderCannotClaimCompletion() throws Exception { + VlcPlayerActivity activity = checkpoint(3_600_000L, 3_600_000L, false); + try { + event(activity, MediaPlayer.Event.EndReached, 0); + assertFalse(activity.isFinishing()); + assertEquals(false, get(activity, "completed")); + } finally { close(activity); } + } + + @Test public void realNearEndStillCompletesNormally() throws Exception { + VlcPlayerActivity activity = checkpoint(3_599_500L, 3_600_000L, true); + try { + event(activity, MediaPlayer.Event.EndReached, 0); + assertTrue(activity.isFinishing()); + assertEquals(true, get(activity, "completed")); + assertEquals(3_600_000L, get(activity, "positionMs")); + } finally { close(activity); } + } + + @Test public void prematureEndDoesNotFinishOrManufacture100Percent() throws Exception { + VlcPlayerActivity activity = checkpoint(180_000L, 3_600_000L, true); + List> results = new ArrayList<>(); + NativePlayerResultGate gate = NativePlayerRequests.INSTANCE.getResults(); + gate.dispose(); + assertTrue(gate.begin("early-eof", 0L, value -> { results.add(value); return Unit.INSTANCE; })); + set(activity, "requestId", "early-eof"); + try { + event(activity, MediaPlayer.Event.EndReached, 0); + assertFalse("An early decoder EOF must not finish the activity and auto-next", activity.isFinishing()); + assertEquals("Keep the actual three-minute checkpoint", 180_000L, get(activity, "positionMs")); + assertEquals(false, get(activity, "completed")); + assertEquals("Show recovery instead of silently skipping the episode", true, get(activity, "error")); + gate.finish("early-eof"); + assertEquals(1, results.size()); + assertEquals(180_000L, results.get(0).get("positionMs")); + assertEquals(3_600_000L, results.get(0).get("durationMs")); + assertEquals(false, results.get(0).get("completed")); + } finally { close(activity); } + } +} diff --git a/android/app/src/test/java/org/reelbox/reelbox/PlayerOverlayAndroidTest.java b/android/app/src/test/java/org/reelbox/reelbox/PlayerOverlayAndroidTest.java new file mode 100644 index 0000000..f805efe --- /dev/null +++ b/android/app/src/test/java/org/reelbox/reelbox/PlayerOverlayAndroidTest.java @@ -0,0 +1,785 @@ +package org.reelbox.reelbox; + +import static org.junit.Assert.*; + +import android.app.Activity; +import android.graphics.Rect; +import android.os.Looper; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.SeekBar; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.GraphicsMode; +import org.robolectric.annotation.LooperMode; + +/** + * Host-side tests of the actual native view, not a policy model or source-string checks. + * PAUSED gives deterministic main Handler/gesture timing. LEGACY tests real Android view + * geometry and touch dispatch, NOT device pixels, native Skia, video decoding or PiP OS UI. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, manifest = Config.NONE, qualifiers = "w360dp-h780dp-mdpi") +@LooperMode(LooperMode.Mode.PAUSED) +@GraphicsMode(GraphicsMode.Mode.LEGACY) +public class PlayerOverlayAndroidTest { + private ActivityController controller; + private PlayerOverlayView overlay; + private RecordingCallbacks callbacks; + private int width = 360; + private int height = 780; + private boolean released; + + private static final class RecordingCallbacks implements PlayerOverlayView.Callbacks { + int closes, toggles, settings, external, pip; + final List relative = new ArrayList<>(); + final List absolute = new ArrayList<>(); + final List absoluteAt = new ArrayList<>(); + final List retries = new ArrayList<>(); + Runnable closeHook, relativeHook; + @Override public void onClose() { closes++; if (closeHook != null) closeHook.run(); } + @Override public void onTogglePlayback() { toggles++; } + @Override public void onSeekRelative(long deltaMs) { + relative.add(deltaMs); + if (relativeHook != null) relativeHook.run(); + } + @Override public void onSeekTo(long positionMs) { + absoluteAt.add(SystemClock.uptimeMillis()); + absolute.add(positionMs); + } + @Override public void onSettings() { settings++; } + @Override public void onExternal() { external++; } + @Override public void onRetry(boolean software) { retries.add(software); } + @Override public void onPictureInPicture() { pip++; } + int actionCount() { + return closes + toggles + settings + external + pip + + relative.size() + absolute.size() + retries.size(); + } + } + + @Before public void createRealAttachedOverlay() { + controller = Robolectric.buildActivity(Activity.class); + controller.get().setTheme(android.R.style.Theme_Material_NoActionBar); + controller.setup().visible(); + callbacks = new RecordingCallbacks(); + overlay = new PlayerOverlayView(controller.get(), callbacks); + controller.get().setContentView(overlay); + overlay.setForeground(true); + overlay.render(state(true, false, false)); + layout(360, 780); + tagged("player-video-gestures"); + } + + @After public void destroyRealAttachedOverlay() { + if (overlay != null && !released) overlay.release(); + if (controller != null) controller.pause().stop().destroy(); + } + + private PlayerOverlayState state(boolean playing, boolean error, boolean pip) { + return stateAt(30_000L, playing, error, pip); + } + + private PlayerOverlayState stateAt(long position, boolean playing, boolean error, boolean pip) { + return new PlayerOverlayState("A long title — عنوان فارسی برای آزمایش رابط ویدیو", + position, 120_000L, playing, false, error, true, 1.0f, pip); + } + + private void layout(int w, int h) { + width = w; height = h; + overlay.measure(View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY)); + overlay.layout(0, 0, w, h); + } + + private View tagged(String tag) { + View result = overlay.findViewWithTag(tag); + assertNotNull("Required real view tag missing: " + tag, result); + return result; + } + + private boolean visible(View view) { + for (View node = view; node != null; ) { + if (node.getVisibility() != View.VISIBLE || node.getAlpha() <= 0f) return false; + if (node == overlay) return true; + node = node.getParent() instanceof View ? (View) node.getParent() : null; + } + return false; + } + + private void assertChrome(boolean expected) { + assertEquals("Chrome visibility", expected, visible(tagged("player-controls"))); + } + + private PlayerControlsState controls() { + try { + java.lang.reflect.Field field = overlay.getClass().getDeclaredField("controls"); + field.setAccessible(true); + return (PlayerControlsState) field.get(overlay); + } catch (ReflectiveOperationException error) { throw new AssertionError(error); } + } + + private void idle(long milliseconds) { + Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(milliseconds)); + } + + private void idleUntil(long uptime) { + long remaining = uptime - SystemClock.uptimeMillis(); + if (remaining > 0) idle(remaining); + } + + private Rect bounds(View child) { + Rect rect = new Rect(0, 0, child.getWidth(), child.getHeight()); + overlay.offsetDescendantRectToMyCoords(child, rect); + return rect; + } + + private void dispatch(int action, float x, float y, long downTime) { + MotionEvent event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0); + try { overlay.dispatchTouchEvent(event); } + finally { event.recycle(); } + } + + private void rawTap(float x, float y) { + long down = SystemClock.uptimeMillis(); + dispatch(MotionEvent.ACTION_DOWN, x, y, down); + idle(16); + dispatch(MotionEvent.ACTION_UP, x, y, down); + // Android View posts PerformClick at the current uptime; drain it without + // advancing through GestureDetector's delayed single-tap confirmation. + Shadows.shadowOf(Looper.getMainLooper()).idle(); + } + + private void tapVideo() { + // Away from header, transport buttons, unlock button and bottom scrub bar. + rawTap(width * 0.17f, height * 0.27f); + idle(ViewConfiguration.getDoubleTapTimeout() + 40L); + } + + private void doubleTapVideo(float fraction) { + rawTap(width * fraction, height * 0.32f); + idle(70); + rawTap(width * fraction, height * 0.32f); + idle(ViewConfiguration.getDoubleTapTimeout() + 40L); + } + + private void tapControl(String tag) { + layout(width, height); + View target = tagged(tag); + assertTrue("Control is not visible: " + tag, visible(target)); + assertTrue("Control disabled: " + tag, target.isEnabled()); + Rect rect = bounds(target); + assertTrue("Control has no touch area: " + tag + " " + rect, rect.width() > 0 && rect.height() > 0); + rawTap(rect.exactCenterX(), rect.exactCenterY()); + } + + @Test public void videoSingleTapHidesAndShowsChrome() { + assertChrome(true); + tapVideo(); + assertChrome(false); + tapVideo(); + assertChrome(true); + } + + @Test public void playingAutoHidesAtThreeSeconds() { + idle(2999); + assertChrome(true); + idle(2); + assertChrome(false); + } + + @Test public void progressRendersDoNotExtendIdleDeadline() { + idle(1000); + overlay.render(stateAt(31_000L, true, false, false)); + idle(1000); + overlay.render(stateAt(32_000L, true, false, false)); + idle(1001); + assertChrome(false); + } + + @Test public void progressRenderNeverReshowsHiddenChrome() { + tapVideo(); + assertChrome(false); + overlay.render(stateAt(31_000L, true, false, false)); + layout(width, height); + assertChrome(false); + idle(4000); + assertChrome(false); + } + + @Test public void scrubHoldsTimerAndReleaseResumesIt() { + View seek = tagged("player-seek"); + assertTrue("Real native SeekBar required", seek instanceof SeekBar); + idle(2000); + // Window traversal can change attached-view bounds. Resolve the live + // touch target AFTER idling, not from a stale pre-traversal rectangle. + Rect rect = bounds(seek); + float y = rect.exactCenterY(); + float x = rect.left + rect.width() * 0.35f; + long down = SystemClock.uptimeMillis(); + dispatch(MotionEvent.ACTION_DOWN, x, y, down); + dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * 0.10f, y, down); + assertTrue("Real SeekBar must receive and track the touch", seek.isPressed()); + assertTrue("Finger tracking must only preview until UP", callbacks.absolute.isEmpty()); + idle(4500); + assertChrome(true); + long stoppedAt = SystemClock.uptimeMillis(); + dispatch(MotionEvent.ACTION_UP, x + rect.width() * 0.10f, y, down); + assertEquals("Finger UP commits exactly once", 1, callbacks.absolute.size()); + long position = callbacks.absolute.get(callbacks.absolute.size() - 1); + assertTrue("Seek target must be within duration: " + position, position >= 0 && position <= 120_000L); + Long deadline = controls().getDeadlineMs(); + assertEquals("Production idle interval stays exactly 3000ms", Long.valueOf(callbacks.absoluteAt.get(0) + 3000), deadline); + // Android's pressed/ripple traversal advances Robolectric uptime during UP + // (observed +15ms). Relative idleFor(2999) AFTER UP crossed the deadline. + // Assert against the production monotonic deadline, without widening it. + System.out.println("SCRUB_CLOCK stoppedAt=" + stoppedAt + " afterUp=" + SystemClock.uptimeMillis() + + " deadline=" + deadline); + idleUntil(deadline - 1); + assertChrome(true); + idleUntil(deadline); + assertChrome(false); + assertEquals(1, callbacks.absolute.size()); + } + + @Test public void errorCancelsActiveScrubWithoutCommittingAndRetryRecovers() { + assertCancelledScrubRecovers("error"); + } + + @Test public void seekabilityLossCancelsActiveScrubWithoutCommitting() { + assertCancelledScrubRecovers("unseekable"); + } + + @Test public void pipCancelsActiveScrubWithoutCommitting() { + assertCancelledScrubRecovers("pip"); + } + + @Test public void lockCancelsActiveScrubWithoutCommitting() { + assertCancelledScrubRecovers("lock"); + } + + @Test public void seekabilityRecoveryBeforeFingerUpCannotCommitCancelledScrub() { + assertRecoveryBeforeFingerUp("unseekable", false); + } + + @Test public void errorRecoveryBeforeFingerUpCannotCommitCancelledScrub() { + assertRecoveryBeforeFingerUp("error", false); + } + + @Test public void pipRecoveryBeforeFingerUpCannotCommitCancelledScrub() { + assertRecoveryBeforeFingerUp("pip", false); + } + + @Test public void unlockBeforeFingerUpCannotCommitCancelledScrub() { + assertRecoveryBeforeFingerUp("lock", false); + } + + @Test public void cancelledTouchSuppressionDoesNotBlockAccessibilitySeeking() { + assertRecoveryBeforeFingerUp("unseekable", true); + } + + private void assertRecoveryBeforeFingerUp(String interruption, boolean accessibility) { + idle(1000); + SeekBar seek = (SeekBar) tagged("player-seek"); + Rect rect = bounds(seek); + float x = rect.left + rect.width() * .35f; + float y = rect.exactCenterY(); + long down = SystemClock.uptimeMillis(); + dispatch(MotionEvent.ACTION_DOWN, x, y, down); + dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .15f, y, down); + assertTrue("Fixture must start actual native tracking", seek.isPressed()); + assertTrue(callbacks.absolute.isEmpty()); + if (interruption.equals("lock")) tagged("player-lock").performClick(); + else overlay.render(new PlayerOverlayState("Interrupted", 45_000L, 120_000L, + true, false, interruption.equals("error"), !interruption.equals("unseekable"), + 1f, interruption.equals("pip"))); + assertFalse(seek.isPressed()); + if (interruption.equals("lock")) tagged("player-unlock").performClick(); + // Recover WHILE the original finger is still down: its parent touch + // target must not be allowed to restart native tracking on MOVE or UP. + overlay.render(stateAt(75_000L, true, false, false)); + layout(width, height); + assertTrue(seek.isEnabled()); + if (accessibility) { + assertTrue(seek.performAccessibilityAction( + android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null)); + assertEquals("Non-touch seeking remains immediate", 1, callbacks.absolute.size()); + callbacks.absolute.clear(); + } + dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .25f, y, down); + assertFalse("Cancelled finger cannot restart native tracking after recovery", seek.isPressed()); + dispatch(MotionEvent.ACTION_UP, x + rect.width() * .25f, y, down); + assertTrue("Cancelled finger cannot commit after recovery", callbacks.absolute.isEmpty()); + assertFalse(seek.isPressed()); + rect = bounds(seek); + long freshDown = SystemClock.uptimeMillis(); + float freshX = rect.left + rect.width() * .3f; + dispatch(MotionEvent.ACTION_DOWN, freshX, rect.exactCenterY(), freshDown); + dispatch(MotionEvent.ACTION_MOVE, freshX + rect.width() * .2f, rect.exactCenterY(), freshDown); + assertTrue("A fresh finger stream must still start tracking", seek.isPressed()); + dispatch(MotionEvent.ACTION_UP, freshX + rect.width() * .2f, rect.exactCenterY(), freshDown); + assertEquals("A fresh scrub commits exactly once", 1, callbacks.absolute.size()); + } + + private void assertCancelledScrubRecovers(String interruption) { + idle(2000); + SeekBar seek = (SeekBar) tagged("player-seek"); + Rect rect = bounds(seek); + float x = rect.left + rect.width() * .35f; + float y = rect.exactCenterY(); + long down = SystemClock.uptimeMillis(); + dispatch(MotionEvent.ACTION_DOWN, x, y, down); + dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .2f, y, down); + assertTrue("Test requires real active SeekBar tracking", seek.isPressed()); + assertTrue("Finger drag is preview only until release", callbacks.absolute.isEmpty()); + if (interruption.equals("lock")) tagged("player-lock").performClick(); + else overlay.render(new PlayerOverlayState("Interrupted", 45_000L, 120_000L, + true, false, interruption.equals("error"), !interruption.equals("unseekable"), + 1f, interruption.equals("pip"))); + assertFalse("Interruption must cancel pressed/tracking before disabling SeekBar", seek.isPressed()); + dispatch(MotionEvent.ACTION_UP, x + rect.width() * .2f, y, down); + assertTrue("Cancelled scrub must never reach decoder callback", callbacks.absolute.isEmpty()); + if (interruption.equals("error")) { + tagged("player-retry").performClick(); + assertEquals(List.of(false), callbacks.retries); + } + if (interruption.equals("lock")) tagged("player-unlock").performClick(); + overlay.render(stateAt(75_000L, true, false, false)); + assertEquals("Recovery must resume real progress snapshots", 6250, seek.getProgress()); + assertEquals("1:15", ((android.widget.TextView) tagged("player-clock")).getText().toString()); + assertTrue(seek.isEnabled()); + Long deadline = controls().getDeadlineMs(); + assertNotNull("Cancelled seek cannot strand visibility hold", deadline); + idleUntil(deadline - 1); + assertChrome(true); + idleUntil(deadline); + assertChrome(false); + } + + @Test public void nativeTouchCancelDoesNotCommitScrub() { + SeekBar seek = (SeekBar) tagged("player-seek"); + Rect rect = bounds(seek); + long down = SystemClock.uptimeMillis(); + dispatch(MotionEvent.ACTION_DOWN, rect.exactCenterX(), rect.exactCenterY(), down); + dispatch(MotionEvent.ACTION_MOVE, rect.right - 30, rect.exactCenterY(), down); + assertTrue(seek.isPressed()); + dispatch(MotionEvent.ACTION_CANCEL, rect.right - 30, rect.exactCenterY(), down); + assertFalse(seek.isPressed()); + assertTrue("ACTION_CANCEL is not a committed seek", callbacks.absolute.isEmpty()); + assertNotNull(controls().getDeadlineMs()); + } + + @Test public void keyboardSeekCommitsImmediatelyAndRenewsVisibleDeadline() { + assertNonTouchSeekCommits(false); + } + + @Test public void accessibilitySeekCommitsImmediatelyAndRenewsVisibleDeadline() { + assertNonTouchSeekCommits(true); + } + + private void assertNonTouchSeekCommits(boolean accessibility) { + SeekBar seek = (SeekBar) tagged("player-seek"); + long oldDeadline = controls().getDeadlineMs(); + idle(2500); + int oldProgress = seek.getProgress(); + long interactedAt = SystemClock.uptimeMillis(); + if (accessibility) assertTrue(seek.performAccessibilityAction( + android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null)); + else { + seek.requestFocus(); + assertTrue(seek.dispatchKeyEvent(new android.view.KeyEvent( + android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_RIGHT))); + seek.dispatchKeyEvent(new android.view.KeyEvent( + android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_RIGHT)); + } + assertTrue("Real user action must move SeekBar", seek.getProgress() > oldProgress); + assertEquals("Non-touch change has no stop-tracking event: commit it immediately once", + List.of(120_000L * seek.getProgress() / 10000), callbacks.absolute); + Long deadline = controls().getDeadlineMs(); + assertNotNull(deadline); + assertTrue("Non-touch seek must renew interaction deadline", deadline >= interactedAt + 3000); + idleUntil(oldDeadline + 1); + assertChrome(true); + idleUntil(deadline - 1); + assertChrome(true); + idleUntil(deadline); + assertChrome(false); + assertEquals("No deferred duplicate seek", 1, callbacks.absolute.size()); + } + + @Test public void maximumNativeDurationSeekCannotOverflow() { + long duration = Long.MAX_VALUE / 1000L; // Accepted native millisecond range. + overlay.render(new PlayerOverlayState("Long source", duration / 4, duration, + true, false, false, true, 1f, false)); + SeekBar seek = (SeekBar) tagged("player-seek"); + seek.setKeyProgressIncrement(10000); + seek.requestFocus(); + assertTrue(seek.dispatchKeyEvent(new android.view.KeyEvent( + android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_RIGHT))); + assertEquals(10000, seek.getProgress()); + assertEquals("End-of-timeline target must equal duration without intermediate overflow", + List.of(duration), callbacks.absolute); + } + + @Test public void openMenuHoldsTimerAndDismissResumesIt() { + idle(2000); + tapControl("player-settings"); + assertEquals("Settings dispatch is immediate", 1, callbacks.settings); + overlay.setMenuOpen(true); + idle(4500); + assertChrome(true); + overlay.render(stateAt(34_000L, true, false, false)); + assertChrome(true); + overlay.setMenuOpen(false); + idle(2999); + assertChrome(true); + idle(2); + assertChrome(false); + } + + @Test public void delayedOldDialogDismissCannotReleaseNestedSheetHold() { + PlayerMenuSession sessions = new PlayerMenuSession(); + android.app.Dialog[] sheets = new android.app.Dialog[2]; + int[] dismissals = {0}; + Object oldOwner = sessions.open(); + overlay.setMenuOpen(true); + sheets[0] = PlayerOverlayViewKt.playerChoiceSheet(controller.get(), "Settings", + List.of("Playback speed"), -1, + () -> { + dismissals[0]++; + if (sessions.close(oldOwner)) overlay.setMenuOpen(false); + return kotlin.Unit.INSTANCE; + }, + index -> { + // Replacement invalidates the old owner BEFORE dismissing it. + Object newOwner = sessions.open(); + sheets[0].dismiss(); + overlay.setMenuOpen(true); + sheets[1] = PlayerOverlayViewKt.playerChoiceSheet(controller.get(), "Playback speed", + List.of("1×", "2×"), 0, + () -> { + if (sessions.close(newOwner)) overlay.setMenuOpen(false); + return kotlin.Unit.INSTANCE; + }, value -> kotlin.Unit.INSTANCE); + return kotlin.Unit.INSTANCE; + }); + View row = findDescription(sheets[0].getWindow().getDecorView(), "Playback speed"); + assertNotNull(row); + row.performClick(); + assertNotNull("Actual choice must open a nested sheet", sheets[1]); + assertEquals("Old onDismiss must still be queued", 0, dismissals[0]); + idle(4000); + assertEquals(1, dismissals[0]); + assertTrue(sheets[1].isShowing()); + assertChrome(true); + assertNull("Nested sheet must retain the auto-hide hold", controls().getDeadlineMs()); + long dismissedAt = SystemClock.uptimeMillis(); + sheets[1].dismiss(); + idleUntil(dismissedAt + 2999); + assertChrome(true); + idleUntil(dismissedAt + 3001); + assertChrome(false); + } + + private View findDescription(View root, String description) { + if (description.equals(String.valueOf(root.getContentDescription()))) return root; + if (root instanceof ViewGroup) { + ViewGroup group = (ViewGroup) root; + for (int i = 0; i < group.getChildCount(); i++) { + View result = findDescription(group.getChildAt(i), description); + if (result != null) return result; + } + } + return null; + } + + @Test public void pausedSingleTapStillHidesChrome() { + overlay.render(state(false, false, false)); + idle(5000); + assertChrome(true); + tapVideo(); + assertChrome(false); + idle(5000); + assertChrome(false); + } + + @Test public void playButtonDispatchesImmediatelyWithoutGestureDelay() { + tapControl("player-play"); + assertEquals(1, callbacks.toggles); + assertChrome(true); + idle(ViewConfiguration.getDoubleTapTimeout() + 40L); + assertEquals("Button must not also bubble into video gesture", 1, callbacks.toggles); + assertChrome(true); + } + + @Test public void leftDoubleTapSeeksBackWithoutSingleTapChromeToggle() { + doubleTapVideo(0.18f); + assertEquals(List.of(-10_000L), callbacks.relative); + assertEquals(0, callbacks.toggles); + assertChrome(true); + } + + @Test public void rightDoubleTapSeeksForwardWithoutSingleTapChromeToggle() { + doubleTapVideo(0.82f); + assertEquals(List.of(10_000L), callbacks.relative); + assertEquals(0, callbacks.toggles); + assertChrome(true); + } + + @Test public void lockedSuppressesChromeGesturesAndSeekingUntilUnlock() { + Rect play = bounds(tagged("player-play")); + Rect seek = bounds(tagged("player-seek")); + tapControl("player-lock"); + assertTrue("Unlock affordance must remain visible", visible(tagged("player-unlock"))); + assertChrome(false); + List lockedTargets = new ArrayList<>(); + collectClickable(overlay, lockedTargets); + lockedTargets.remove(tagged("player-video-gestures")); // Consuming surface, tested below. + assertEquals("Unlock must be the only active visible control", List.of(tagged("player-unlock")), lockedTargets); + int before = callbacks.actionCount(); + tapVideo(); + doubleTapVideo(0.18f); + doubleTapVideo(0.82f); + rawTap(play.exactCenterX(), play.exactCenterY()); + rawTap(seek.left + seek.width() * 0.8f, seek.exactCenterY()); + assertEquals("Locked controls or gestures dispatched a callback", before, callbacks.actionCount()); + assertChrome(false); + assertTrue(visible(tagged("player-unlock"))); + tapControl("player-unlock"); + assertChrome(true); + assertFalse(visible(tagged("player-unlock"))); + tapControl("player-play"); + assertEquals(1, callbacks.toggles); + } + + @Test public void pictureInPictureHidesAllOverlayUiAndSuppressesTaps() { + overlay.render(state(true, false, true)); + layout(width, height); + assertChrome(false); + for (String tag : List.of("player-play", "player-back", "player-external", "player-settings", + "player-lock", "player-unlock", "player-seek", "player-loading", "player-error")) { + assertFalse("PiP leaked visible overlay: " + tag, visible(tagged(tag))); + } + List pipTargets = new ArrayList<>(); + collectClickable(overlay, pipTargets); + assertTrue("PiP must expose no active overlay targets", pipTargets.isEmpty()); + int before = callbacks.actionCount(); + tapVideo(); + doubleTapVideo(0.18f); + doubleTapVideo(0.82f); + idle(4000); + assertEquals(before, callbacks.actionCount()); + assertChrome(false); + } + + @Test public void errorClearsLockAndPresentsReachableRecovery() { + tapControl("player-lock"); + assertTrue(visible(tagged("player-unlock"))); + overlay.render(state(false, true, false)); + layout(width, height); + assertFalse("Error must clear lock", visible(tagged("player-unlock"))); + View error = tagged("player-error"); + assertTrue("Recovery panel must be visible", visible(error)); + List recoveryButtons = new ArrayList<>(); + collectClickable(error, recoveryButtons); + assertFalse("Recovery panel must offer an actionable control", recoveryButtons.isEmpty()); + // Click real recovery controls; onRetry(false/true) must be reachable, not just text. + for (View button : recoveryButtons) { + Rect rect = bounds(button); + rawTap(rect.exactCenterX(), rect.exactCenterY()); + } + assertTrue("Retry must be reachable from error recovery UI", callbacks.retries.contains(false)); + idle(5000); + assertTrue("Error recovery cannot auto-hide", visible(error)); + } + + private void collectClickable(View view, List result) { + if (!visible(view)) return; + if (view.isClickable() && view.isEnabled()) result.add(view); + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) collectClickable(group.getChildAt(i), result); + } + } + + @Test public void releaseCancelsPendingAutoHideAndGestureCallbacks() throws Exception { + // Check real Android Handler queue removal, not merely a released-state guard. + rawTap(width * 0.17f, height * 0.27f); + assertChrome(true); + List handlers = new ArrayList<>(); + boolean hadPendingCallbacks = false; + for (java.lang.reflect.Field field : overlay.getClass().getDeclaredFields()) { + if (!android.os.Handler.class.isAssignableFrom(field.getType())) continue; + field.setAccessible(true); + android.os.Handler handler = (android.os.Handler) field.get(overlay); + assertSame("Overlay Handler must use Android's main looper", Looper.getMainLooper(), handler.getLooper()); + handlers.add(handler); + hadPendingCallbacks |= handler.hasMessages(0); // Runnable posts use what=0. + } + assertFalse("Expected real overlay Handler", handlers.isEmpty()); + assertTrue("Test must start with actual timer callbacks queued", hadPendingCallbacks); + int before = callbacks.actionCount(); + overlay.release(); + released = true; + for (android.os.Handler handler : handlers) { + assertFalse("release left callbacks on the overlay Handler", handler.hasMessages(0)); + } + boolean chromeAfterRelease = visible(tagged("player-controls")); + idle(10_000); + assertEquals(before, callbacks.actionCount()); + assertEquals("No delayed gesture may change released UI", chromeAfterRelease, visible(tagged("player-controls"))); + } + + @Test public void closeCallbackReleaseFreezesLastFrameAndLeavesNoCallbacks() throws Exception { + String[] frozen = new String[1]; + callbacks.closeHook = () -> { + frozen[0] = uiSnapshot(overlay); + overlay.release(); + released = true; + }; + tagged("player-back").performClick(); + assertEquals(1, callbacks.closes); + assertEquals("Click wrapper must not repaint after callback released overlay", frozen[0], uiSnapshot(overlay)); + assertReleasedHasNoCallbacks(); + idle(10_000); + assertEquals(frozen[0], uiSnapshot(overlay)); + assertEquals(1, callbacks.actionCount()); + } + + @Test public void gestureCallbackReleaseCannotPaintOrScheduleSeekFeedback() throws Exception { + String[] frozen = new String[1]; + callbacks.relativeHook = () -> { + frozen[0] = uiSnapshot(overlay); + overlay.release(); + released = true; + }; + doubleTapVideo(.82f); + assertNotNull(frozen[0]); + assertEquals("Double tap must stop after callback releases overlay", frozen[0], uiSnapshot(overlay)); + assertReleasedHasNoCallbacks(); + idle(10_000); + assertEquals(frozen[0], uiSnapshot(overlay)); + assertEquals(1, callbacks.actionCount()); + } + + private void assertReleasedHasNoCallbacks() throws Exception { + for (java.lang.reflect.Field field : overlay.getClass().getDeclaredFields()) { + if (!android.os.Handler.class.isAssignableFrom(field.getType())) continue; + field.setAccessible(true); + android.os.Handler handler = (android.os.Handler) field.get(overlay); + assertFalse("Released overlay scheduled a later callback", handler.hasMessages(0)); + } + } + + private String uiSnapshot(View view) { + StringBuilder result = new StringBuilder(); + result.append(view.getClass().getSimpleName()).append(':').append(view.getTag()) + .append(':').append(view.getVisibility()).append(':').append(view.isEnabled()) + .append(':').append(view.getAlpha()).append(':').append(view.getContentDescription()); + if (view instanceof android.widget.TextView) result.append(':').append(((android.widget.TextView) view).getText()); + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) result.append('[').append(uiSnapshot(group.getChildAt(i))).append(']'); + } + return result.toString(); + } + + @Test public void backgroundStopsTimerAndForegroundResumesIt() { + idle(2000); + overlay.setForeground(false); + idle(5000); + assertChrome(true); + long resumedAt = SystemClock.uptimeMillis(); + overlay.setForeground(true); + // Frame traversal inside setForeground may itself advance shadow time. + // Anchor to the interaction, with a small vsync allowance at the boundary. + idleUntil(resumedAt + 2950); + assertChrome(true); + idleUntil(resumedAt + 3050); + assertChrome(false); + } + + @Test public void closeAndExternalButtonsDispatchThroughRealTouch() { + tapControl("player-external"); + assertEquals(1, callbacks.external); + tapControl("player-back"); + assertEquals(1, callbacks.closes); + assertEquals(0, callbacks.toggles); + } + + @Test public void safeInsetsProtectControlsWithoutLeavingScrimGap() { + for (int[] size : new int[][] {{360, 780}, {780, 360}}) { + overlay.setSafeInsets(28, 32, 16, 24); + layout(size[0], size[1]); + View header = (View) tagged("player-back").getParent(); + assertNotNull("Header owns the actual top shadow", header.getBackground()); + assertEquals("Top scrim must start at the physical top, not below the safe area", 0, bounds(header).top); + assertEquals(0, bounds(header).left); + assertEquals(size[0], bounds(header).right); + assertEquals(32, bounds(tagged("player-back")).top); + assertEquals(28, bounds(tagged("player-back")).left); + assertEquals(size[0] - 16, bounds(tagged("player-external")).right); + assertEquals(size[1] - 24, bounds(tagged("player-settings")).bottom); + assertEquals(new Rect(0, 0, size[0], size[1]), bounds(tagged("player-video-gestures"))); + overlay.setSafeInsets(0, 0, 0, 0); + layout(size[0], size[1]); + assertEquals("Immersive/PiP inset removal must move controls back to the top", 0, + bounds(tagged("player-back")).top); + } + } + + @Test public void portraitControlsStayInsideViewportAndDoNotOverlap() { + assertGeometry(360, 780); + } + + @Test public void landscapeControlsStayInsideViewportAndDoNotOverlap() { + assertGeometry(780, 360); + } + + private void assertGeometry(int w, int h) { + layout(w, h); + Rect viewport = new Rect(0, 0, w, h); + List controls = new ArrayList<>(); + for (String tag : List.of("player-play", "player-back", "player-external", "player-settings", + "player-lock", "player-seek", "player-clock", "player-remaining")) { + View view = tagged(tag); + assertTrue("Required control not visible in " + w + "x" + h + ": " + tag, visible(view)); + Rect rect = bounds(view); + System.out.println("HOST_LAYOUT " + w + "x" + h + " " + tag + "=" + rect); + assertTrue("Zero-sized control " + tag + ": " + rect, rect.width() > 0 && rect.height() > 0); + assertTrue("Offscreen control " + tag + ": " + rect + " viewport=" + viewport, viewport.contains(rect)); + controls.add(view); + } + // Also cover real extra controls (e.g. PiP and rewind), without prescribing + // extra tags beyond the shared interface or overlooking sibling additions. + List extraTargets = new ArrayList<>(); + collectClickable(tagged("player-controls"), extraTargets); + for (View view : extraTargets) { + if (controls.contains(view)) continue; + Rect rect = bounds(view); + System.out.println("HOST_LAYOUT " + w + "x" + h + " " + view.getTag() + "=" + rect); + assertTrue("Zero-sized touch target: " + view.getTag(), rect.width() > 0 && rect.height() > 0); + assertTrue("Offscreen touch target " + view.getTag() + ": " + rect, viewport.contains(rect)); + controls.add(view); + } + for (int i = 0; i < controls.size(); i++) { + for (int j = i + 1; j < controls.size(); j++) { + View a = controls.get(i), b = controls.get(j); + assertFalse("Overlapping controls " + a.getTag() + "=" + bounds(a) + + " and " + b.getTag() + "=" + bounds(b), Rect.intersects(bounds(a), bounds(b))); + } + } + } +} diff --git a/android/app/src/test/kotlin/org/reelbox/reelbox/NativePlayerPolicyTest.kt b/android/app/src/test/kotlin/org/reelbox/reelbox/NativePlayerPolicyTest.kt new file mode 100644 index 0000000..c66e4d6 --- /dev/null +++ b/android/app/src/test/kotlin/org/reelbox/reelbox/NativePlayerPolicyTest.kt @@ -0,0 +1,79 @@ +package org.reelbox.reelbox.tests + +import org.reelbox.reelbox.NativePlayerLaunch + +/** JVM-only tests, run without an Android runtime or network. */ +fun main() { + val source = "https://media.example.org/a%2Fb.m3u8?sig=abc%2Bz&x=1" + val launch = NativePlayerLaunch.from(mapOf("url" to source, "positionMs" to 12345L, + "autoplay" to false, "title" to "عنوان"), emptyList()) + check(launch.url == source) + check(launch.positionMs == 12345L) + check(!launch.autoplay) + check(launch.title == "عنوان") + val root = java.nio.file.Files.createTempDirectory("native-player-policy").toFile() + try { + val local = java.io.File(root, "episode.mkv").apply { writeText("fixture") } + check(NativePlayerLaunch.from(mapOf("url" to local.path), listOf(root)).url == local.canonicalPath) + check(NativePlayerLaunch.from(mapOf("url" to local.toURI().toString()), listOf(root)).url == local.canonicalPath) + val loopback = "http://127.0.0.1:49321/media/version/a.m3u8?token=opaque" + check(!org.reelbox.reelbox.NativeUrlPolicy.allowsRemote(loopback)) + check(NativePlayerLaunch.from(mapOf("url" to loopback), emptyList()).url == loopback) + val invalid = listOf("content://foreign/provider", "/etc/passwd", "file:///etc/passwd", + "https://u:p@example.org/media.mp4", "javascript:alert(1)", + java.io.File(root, "../outside.mp4").path, "file://evil.example${local.path}", + "https://t.me/channel", java.io.File(root, "video.apk").apply { writeText("fixture") }.path) + for (url in invalid) { + check(runCatching { NativePlayerLaunch.from(mapOf("url" to url), listOf(root)) }.isFailure) + } + val outside = java.io.File(root.parentFile, "outside-native-test.mkv").apply { writeText("fixture") } + try { + val link = java.io.File(root, "link.mkv") + java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath()) + check(runCatching { NativePlayerLaunch.from(mapOf("url" to link.path), listOf(root)) }.isFailure) + } finally { outside.delete() } + val options = NativePlayerLaunch.from(mapOf("url" to source, "speed" to 1.25, + "orientation" to "landscape", "videoFit" to "cover", "externalPackage" to "vlc", + "subtitleEnabled" to false, "subtitleUrl" to local.path, "pipEnabled" to false, + "hardwareAcceleration" to false, "positionMs" to -3), listOf(root)) + check(options.speed == 1.25f && options.orientation == "landscape" && options.videoFit == "cover") + check(options.externalPackage == "vlc" && !options.subtitleEnabled && options.subtitleUrl == local.canonicalPath) + check(!options.pipEnabled && !options.hardwareAcceleration && options.positionMs == 0L) + check(runCatching { NativePlayerLaunch.from(mapOf("url" to source, "speed" to Double.NaN), emptyList()) }.isFailure) + check(runCatching { NativePlayerLaunch.from(mapOf("url" to source, "externalPackage" to "a;b"), emptyList()) }.isFailure) + } finally { root.deleteRecursively() } + val gate = org.reelbox.reelbox.NativePlayerResultGate() + val delivered = mutableListOf>() + check(gate.begin("title:007/source:a%2Fb", 12000L) { delivered.add(it) }) + check(!gate.begin("other", 0L) { error("Busy request replaced") }) + check(!gate.update("wrong", 1L, 2L, true, true)) + check(!gate.finish("wrong")) + check(delivered.isEmpty()) + check(gate.update("title:007/source:a%2Fb", 32000L, 90000L, true, false)) + check(gate.finish("title:007/source:a%2Fb")) + check(!gate.finish("title:007/source:a%2Fb")) + check(delivered.single() == mapOf("positionMs" to 32000L, "durationMs" to 90000L, + "opened" to true, "completed" to false)) + check(gate.begin("retry", -7L) { delivered.add(it) }) + gate.update("retry", Long.MAX_VALUE, 123L, true, true) + gate.finish("retry") + check(delivered.last()["positionMs"] == 123L && delivered.last()["completed"] == true) + check(gate.begin("destroy", 42L) { delivered.add(it) }) + gate.dispose() + gate.dispose() + check(delivered.size == 3 && delivered.last()["positionMs"] == 42L && delivered.last()["opened"] == false) + check(gate.begin("reentrant", 0L) { check(gate.begin("next", 0L) {}) }) + check(gate.finish("reentrant")) + check(gate.activeId == "next") + gate.dispose() + check(gate.begin("detached-engine", 9L) { throw IllegalStateException("Flutter engine detached") }) + check(runCatching { gate.finish("detached-engine") }.getOrDefault(false)) + check(gate.activeId == null) + check(!gate.finish("detached-engine")) + check(gate.begin("oversized-metadata", Long.MAX_VALUE) { delivered.add(it) }) + gate.update("oversized-metadata", Long.MAX_VALUE, Long.MAX_VALUE, true, false) + gate.finish("oversized-metadata") + check(delivered.last()["durationMs"] == Long.MAX_VALUE / 1000L) + check(delivered.last()["positionMs"] == Long.MAX_VALUE / 1000L) + println("Native player launch/policy and exactly-once result regression cases passed") +} diff --git a/android/app/src/test/kotlin/org/reelbox/reelbox/NativePolicyTest.kt b/android/app/src/test/kotlin/org/reelbox/reelbox/NativePolicyTest.kt new file mode 100644 index 0000000..200159d --- /dev/null +++ b/android/app/src/test/kotlin/org/reelbox/reelbox/NativePolicyTest.kt @@ -0,0 +1,38 @@ +package org.reelbox.reelbox.tests + +import org.reelbox.reelbox.NativeUrlPolicy + +/** JVM executable: no Android runtime, credentials, network, or Gradle. */ +fun main() { + var assertions = 0 + fun expect(condition: Boolean, message: () -> Any = { "Assertion failed" }) { + assertions++ + check(condition, message) + } + expect(NativeUrlPolicy.allowsRemote("https://media.example.org/movie.m3u8?token=opaque")) + expect(NativeUrlPolicy.allowsRemote("http://192.168.1.4/video.mp4")) + for (url in listOf( + "javascript:alert(1)", "intent://play", "file:///sdcard/video.mp4", "https://t.me/channel", + "https://example.org/app.APK?download=1", "https://example.org/%75pdate/latest", + "https://example.org/support/contact", "https://example.org/telegram", "https://melodybox.example/store", + "https://norton.com/promo", "https://example.org/news", "https://example.org/shop", + "https://user:pass@example.org/video.mp4", "https://example.org/app%252eapk", + "https://example.org/مَلُودِي%20باكس", "https://example.org/نُورْتُون", + "https://example.org/setup.exe", "https://example.org/app.dmg", "https://example.org/install.ipa", + "https://example.org/video%5cplayer.mp4", "https://example.org/%2575pdate/latest", + "https://example.org/%D8%AA%D9%84%DA%AF%D8%B1%D8%A7%D9%85", + "https://example.org/?next=https%3A%2F%2Ft.me%2Fchannel", "https://example.org/?next=melodybox" + )) expect(!NativeUrlPolicy.allowsRemote(url)) { "Unsafe URL accepted: $url" } + expect(NativeUrlPolicy.safePackage("org.videolan.vlc")) + expect(!NativeUrlPolicy.safePackage("bad;package")) + expect(!NativeUrlPolicy.allowsSharedFilename("update.APK")) + expect(!NativeUrlPolicy.allowsSharedFilename("installer.msi")) + expect(NativeUrlPolicy.allowsSharedFilename("episode.mkv")) + expect(NativeUrlPolicy.allowsSharedFilename("subtitle.vtt")) + val first = org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("title" to "Film", "playing" to true, "positionMs" to -20, "durationMs" to 100)) + expect(first.positionMs == 0L && first.playing && first.title == "Film") + val next = org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("positionMs" to 999), first) + expect(next.positionMs == 100L && next.durationMs == 100L && next.title == "Film") + expect(org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("durationMs" to -9)).durationMs == 0L) + println("Native URL policy and playback snapshots: $assertions assertions passed") +} diff --git a/android/app/src/test/kotlin/org/reelbox/reelbox/PlayerControlsStateTest.kt b/android/app/src/test/kotlin/org/reelbox/reelbox/PlayerControlsStateTest.kt new file mode 100644 index 0000000..d71725a --- /dev/null +++ b/android/app/src/test/kotlin/org/reelbox/reelbox/PlayerControlsStateTest.kt @@ -0,0 +1,178 @@ +package org.reelbox.reelbox.tests + +import org.reelbox.reelbox.PlayerControlsState +import org.reelbox.reelbox.PlayerMenuSession +import org.reelbox.reelbox.PlayerSeekMath +import java.math.BigInteger + +private var passed = 0 +private var failed = 0 +private fun case(name: String, run: () -> Unit) { + try { + run() + passed++ + println("PASS $name") + } catch (failure: Throwable) { + failed++ + println("FAIL $name: ${failure.message}") + } +} +private fun expect(value: Boolean, message: String) { check(value) { message } } + +fun main() { + case("playing idle hides at precisely 3000ms; progress renders preserve deadline") { + val controls = PlayerControlsState() + controls.playback(playing = true, loading = false, error = false, pip = false, now = 0) + for (now in listOf(500L, 1000L, 2000L, 2999L)) { + controls.playback(true, false, false, false, now) + controls.tick(now) + expect(controls.chromeVisible, "progress render hid controls before deadline") + } + controls.tick(3000) + expect(!controls.chromeVisible, "idle playback did not hide") + controls.playback(true, false, false, false, 3500) + expect(!controls.chromeVisible, "progress render reopened controls") + } + case("buffering does not force chrome or consume the playing deadline") { + val controls = PlayerControlsState() + controls.playback(true, true, false, false, 0) + controls.tick(5000) + expect(controls.chromeVisible, "initial buffering hid controls before actual playback") + controls.playback(true, false, false, false, 6000) + controls.tick(8999) + expect(controls.chromeVisible, "buffering consumed playback idle time") + controls.tick(9000) + expect(!controls.chromeVisible, "playback did not hide after buffering") + } + case("PiP suppresses chrome and cancels timers; exit restores controls") { + val controls = PlayerControlsState() + controls.playback(true, false, false, true, 0) + expect(!controls.chromeVisible, "PiP retained controls") + expect(controls.deadlineMs == null, "PiP retained timer") + controls.playback(true, false, false, false, 5000) + expect(controls.chromeVisible, "PiP exit did not restore controls") + } + case("error reveals recovery after idle hide") { + val controls = PlayerControlsState() + controls.playback(true, false, false, false, 0) + controls.tick(3000) + controls.playback(false, false, true, false, 4000) + expect(controls.chromeVisible, "error recovery remained hidden") + expect(controls.deadlineMs == null, "error scheduled auto hide") + } + case("manual tap hides paused and loading controls without reopening on ticks") { + for (loading in listOf(false, true)) { + val controls = PlayerControlsState() + controls.playback(false, loading, false, false, 0) + controls.tap(100) + expect(!controls.chromeVisible, "manual tap did not hide paused/loading chrome") + controls.playback(false, loading, false, false, 1000) + expect(!controls.chromeVisible, "render reopened manually hidden controls") + controls.tap(1200) + expect(controls.chromeVisible, "manual tap did not restore controls") + } + } + case("seeking and menus hold visibility then restart idle timeout on release") { + for (menu in listOf(false, true)) { + val controls = PlayerControlsState() + controls.playback(true, false, false, false, 0) + if (menu) controls.menu(true, 2500) else controls.seeking(true, 2500) + controls.tick(12000) + expect(controls.chromeVisible && controls.deadlineMs == null, "active interaction auto hid") + if (menu) controls.menu(false, 12000) else controls.seeking(false, 12000) + controls.tick(14999) + expect(controls.chromeVisible, "interaction release did not grant full idle interval") + controls.tick(15000) + expect(!controls.chromeVisible, "interaction release never auto hid") + } + } + case("lock suppresses controls, unlock restores, error always unlocks recovery") { + val controls = PlayerControlsState() + controls.playback(true, false, false, false, 0) + controls.lock(true, 100) + expect(controls.locked && !controls.chromeVisible, "lock retained chrome") + controls.tap(200) + expect(!controls.chromeVisible, "tap bypassed lock") + controls.lock(false, 300) + expect(controls.chromeVisible && !controls.locked, "unlock did not restore controls") + controls.lock(true, 400) + controls.playback(false, false, true, false, 500) + expect(!controls.locked && controls.chromeVisible, "error recovery trapped behind lock") + } + case("PiP suppresses lock affordance and ignores video interaction") { + val controls = PlayerControlsState() + controls.playback(true, false, false, false, 0) + controls.lock(true, 100) + controls.playback(true, false, false, true, 200) + controls.tap(300) + expect(!controls.chromeVisible && !controls.unlockVisible, "PiP overlay leakage") + } + case("background and release cancel scheduling; release ignores all later events") { + val controls = PlayerControlsState() + controls.playback(true, false, false, false, 0) + controls.foreground(false, 500) + expect(controls.deadlineMs == null, "background timer remained live") + controls.foreground(true, 7000) + expect(controls.deadlineMs == 10000L, "foreground did not grant fresh idle interval") + controls.release() + controls.tap(9000) + controls.playback(true, false, true, false, 10000) + controls.foreground(true, 12000) + expect(controls.deadlineMs == null && !controls.chromeVisible, "released policy scheduled or reopened") + } + case("explicit control interaction renews timeout; overlapping holds release independently") { + val controls = PlayerControlsState() + controls.playback(true, false, false, false, 0) + controls.interact(2500) + controls.tick(3000) + expect(controls.chromeVisible, "button interaction failed to renew timeout") + controls.seeking(true, 4000) + controls.menu(true, 4200) + controls.seeking(false, 5000) + expect(controls.deadlineMs == null, "seek completion ignored open menu") + controls.menu(false, 6000) + expect(controls.deadlineMs == 9000L, "last hold release did not restart timer") + } + case("old sheet dismissal cannot end a newer sheet's visibility hold") { + val menus = PlayerMenuSession() + val old = menus.open() + val current = menus.open() + expect(!menus.close(old), "late old onDismiss cleared the new sheet hold") + expect(menus.close(current), "current sheet could not release its hold") + expect(!menus.close(current), "duplicate dismissal released a hold twice") + } + case("normal progress conversion preserves floor millisecond targets") { + expect(PlayerSeekMath.fromProgress(120_000, 2500) == 30_000L, "normal quarter seek") + expect(PlayerSeekMath.fromProgress(123_457, 3333) == 41_148L, "fractional millisecond flooring") + } + case("accepted native maximum duration reaches exact end without overflow") { + val duration = Long.MAX_VALUE / 1000L + expect(PlayerSeekMath.fromProgress(duration, 10000) == duration, "native maximum wrapped before clamp") + } + case("full Long duration progress matches arbitrary precision across boundaries") { + for (duration in listOf(0L, 1L, 9999L, 10_001L, Long.MAX_VALUE / 1000, Long.MAX_VALUE)) { + for (progress in listOf(0, 1, 3333, 5000, 9999, 10000)) { + val expected = BigInteger.valueOf(duration).multiply(BigInteger.valueOf(progress.toLong())) + .divide(BigInteger.valueOf(10000)).toLong() + expect(PlayerSeekMath.fromProgress(duration, progress) == expected, + "duration=$duration progress=$progress expected=$expected") + } + } + } + case("progress clamps invalid duration and out-of-range thumb values") { + expect(PlayerSeekMath.fromProgress(-1, 5000) == 0L, "negative duration") + expect(PlayerSeekMath.fromProgress(Long.MIN_VALUE, 10000) == 0L, "minimum duration") + expect(PlayerSeekMath.fromProgress(120_000, -1) == 0L, "negative progress") + expect(PlayerSeekMath.fromProgress(120_000, Int.MAX_VALUE) == 120_000L, "oversized progress") + } + case("relative seek saturates rather than wrapping and clamps negative positions") { + expect(PlayerSeekMath.relative(30_000, 10_000) == 40_000L, "normal forward") + expect(PlayerSeekMath.relative(30_000, -10_000) == 20_000L, "normal rewind") + expect(PlayerSeekMath.relative(Long.MAX_VALUE - 5, 10_000) == Long.MAX_VALUE, "positive overflow") + expect(PlayerSeekMath.relative(Long.MAX_VALUE, Long.MAX_VALUE) == Long.MAX_VALUE, "max delta overflow") + expect(PlayerSeekMath.relative(5, Long.MIN_VALUE) == 0L, "rewind below zero") + expect(PlayerSeekMath.relative(-5, 10) == 10L, "negative starting position") + } + println("Player controls: $passed tests passed; $failed failed") + check(failed == 0) { "$failed controls regression(s) failed" } +} diff --git a/android/app/src/test/kotlin/org/reelbox/reelbox/RestrictedResourceFetchTest.kt b/android/app/src/test/kotlin/org/reelbox/reelbox/RestrictedResourceFetchTest.kt new file mode 100644 index 0000000..49baa38 --- /dev/null +++ b/android/app/src/test/kotlin/org/reelbox/reelbox/RestrictedResourceFetchTest.kt @@ -0,0 +1,353 @@ +package org.reelbox.reelbox.tests + +import com.sun.net.httpserver.HttpServer +import org.reelbox.reelbox.RestrictedResourceFetcher +import java.net.InetSocketAddress +import java.net.HttpURLConnection +import java.net.URL +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import javax.net.ssl.SSLHandshakeException +import java.util.concurrent.CopyOnWriteArrayList + +/** Deterministic connection seam: not a device TLS or WebView simulation. */ +private class FixtureConnection( + url: URL, + private val code: Int = 200, + private val fields: Map> = emptyMap(), + private val payload: InputStream = ByteArrayInputStream("public".toByteArray()), + private val failure: IOException? = null, + private val phrase: String = "", +) : HttpURLConnection(url) { + var closed = false + var statusReads = 0 + override fun connect() {} + override fun disconnect() { closed = true } + override fun usingProxy() = false + override fun getResponseCode(): Int { + check(!instanceFollowRedirects) { "Automatic redirects enabled" } + check(connectTimeout in 1..30_000 && readTimeout in 1..30_000) { "Unbounded network wait" } + statusReads++ + failure?.let { throw it } + return code + } + override fun getResponseMessage(): String = phrase + override fun getHeaderFields(): Map> = fields + override fun getHeaderField(name: String): String? = fields.entries.firstOrNull { it.key.equals(name, true) }?.value?.firstOrNull() + override fun getInputStream(): InputStream = payload + override fun getErrorStream(): InputStream = payload +} + +/** Real loopback HTTP; no remote hosts, accounts, Android runtime or Gradle. */ +fun main(args: Array) { + val selected = args.firstOrNull() ?: "all" + var passed = 0 + fun test(name: String, body: () -> Unit) { + if (selected != "all" && selected != name) return + body() + passed++ + println("PASS $name") + } + test("blocked_redirect") { + val hits = CopyOnWriteArrayList() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + hits.add(exchange.requestURI.path) + if (exchange.requestURI.path == "/redirect.js") { + exchange.responseHeaders.add("Location", "/news/script.js") + exchange.sendResponseHeaders(302, -1) + } else { + val bytes = "forbidden-marker".toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + exchange.close() + } + server.start() + try { + val url = "http://127.0.0.1:${server.address.port}/redirect.js" + val response = RestrictedResourceFetcher().fetch(url) + response.body.close() + check(response.statusCode == 403) { "Forbidden redirect must fail closed" } + check(hits == listOf("/redirect.js")) { "Forbidden target requested: $hits" } + } finally { server.stop(0) } + } + test("allowed_redirect_metadata") { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + if (exchange.requestURI.path == "/entry.js") { + exchange.responseHeaders.add("Location", "./assets/player.js") + exchange.sendResponseHeaders(307, -1) + } else { + val bytes = "public-player".toByteArray() + exchange.responseHeaders.add("Content-Type", "application/javascript; charset=\"UTF-8\"") + exchange.responseHeaders.add("Access-Control-Allow-Origin", "https://embed.example.org") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + exchange.close() + } + server.start() + try { + val base = "http://127.0.0.1:${server.address.port}" + val response = RestrictedResourceFetcher().fetch("$base/entry.js") + check(response.statusCode == 200 && response.reasonPhrase.isNotBlank()) + check(response.mimeType == "application/javascript" && response.encoding == "UTF-8") + check(response.effectiveUrl == "$base/assets/player.js") + check(response.headers.entries.any { it.key.equals("Access-Control-Allow-Origin", true) && it.value == "https://embed.example.org" }) + check(response.body.use { String(it.readBytes()) } == "public-player") + for (path in listOf("/collections/private.m3u8", "/episodes/playlist.m3u8")) { + val playlist = RestrictedResourceFetcher().fetch("$base$path") + check(playlist.statusCode == 200 && playlist.body.use { String(it.readBytes()) } == "public-player") + } + } finally { server.stop(0) } + } + test("redirect_boundaries") { + val hits = CopyOnWriteArrayList() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + val path = exchange.requestURI.path + hits.add(path) + val target = when (path) { + "/loop.js" -> "/loop.js" + "/malformed.js" -> "http://[invalid" + "/empty.js" -> null + "/chain.js" -> "/second.js" + "/second.js" -> "/%256eews/script.js" + else -> "/news/script.js" + } + if (target != null) exchange.responseHeaders.add("Location", target) + exchange.sendResponseHeaders(path.removePrefix("/code-").toIntOrNull() ?: 302, -1) + exchange.close() + } + server.start() + try { + val fetcher = RestrictedResourceFetcher() + val base = "http://127.0.0.1:${server.address.port}" + val loop = fetcher.fetch("$base/loop.js") + loop.body.close() + check(loop.statusCode == 508 && hits.size == 6) { "Redirect budget must be five: ${loop.statusCode}, ${hits.size}" } + for (path in listOf("malformed.js", "empty.js")) { + val response = fetcher.fetch("$base/$path") + response.body.close() + check(response.statusCode == 502) + } + hits.clear() + val chain = fetcher.fetch("$base/chain.js") + chain.body.close() + check(chain.statusCode == 403 && hits == listOf("/chain.js", "/second.js")) + for (code in listOf(301, 302, 303, 307, 308)) { + hits.clear() + val response = fetcher.fetch("$base/code-$code") + response.body.close() + check(response.statusCode == 403 && hits == listOf("/code-$code")) + } + hits.clear() + for (url in listOf("not a URI", "$base/news/script.js", "file:///unreadable", "content://unreadable", "data:text/plain,test")) { + val response = fetcher.fetch(url) + response.body.close() + check(response.statusCode == 403) + } + check(hits.isEmpty()) + } finally { server.stop(0) } + } + test("range_head_and_headers") { + val seen = CopyOnWriteArrayList>>>() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + seen.add(exchange.requestMethod to exchange.requestHeaders.toMap()) + if (exchange.requestURI.path == "/entry.mp4") { + exchange.responseHeaders.add("Location", "/video.mp4") + exchange.sendResponseHeaders(307, -1) + exchange.close() + return@createContext + } + exchange.responseHeaders.add("Content-Type", "video/mp4") + exchange.responseHeaders.add("Accept-Ranges", "bytes") + exchange.responseHeaders.add("Content-Range", "bytes 2-5/10") + exchange.responseHeaders.add("Set-Cookie", "fixture=public; Path=/") + exchange.responseHeaders.add("Connection", "keep-alive, X-Hop-Only") + exchange.responseHeaders.add("X-Hop-Only", "must-not-forward") + exchange.responseHeaders.add("ETag", "\"fixture-range\"") + if (exchange.requestMethod == "HEAD") { + exchange.responseHeaders.add("Content-Length", "4") + exchange.sendResponseHeaders(206, -1) + } else { + exchange.sendResponseHeaders(206, 4) + exchange.responseBody.use { it.write("2345".toByteArray()) } + } + exchange.close() + } + server.start() + try { + val fetcher = RestrictedResourceFetcher() + val url = "http://127.0.0.1:${server.address.port}/entry.mp4" + val requestHeaders = mapOf("Range" to "bytes=2-5", "If-Range" to "\"fixture-range\"", + "User-Agent" to "PublicEmbedFixture", "Authorization" to "fixture-only", + "Cookie" to "fixture=not-a-real-session", "Proxy-Authorization" to "fixture-only", + "X-Api-Key" to "fixture-only", "Accept-Encoding" to "gzip", "Host" to "wrong.invalid", + "If-None-Match" to "\"fixture\"") + for (method in listOf("GET", "HEAD")) { + val response = fetcher.fetch(url, method, requestHeaders) + check(response.statusCode == 206 && response.mimeType == "video/mp4" && response.encoding == null) + check(response.headers.entries.any { it.key.equals("Content-Range", true) && it.value == "bytes 2-5/10" }) + check(response.headers.entries.any { it.key.equals("Content-Length", true) && it.value == "4" }) + check(response.headers.keys.none { it.lowercase() in listOf("set-cookie", "connection", "x-hop-only", "transfer-encoding") }) + check(response.body.use { String(it.readBytes()) } == if (method == "HEAD") "" else "2345") + } + check(seen.map { it.first } == listOf("GET", "GET", "HEAD", "HEAD")) + for ((_, headers) in seen) { + fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, true) }?.value?.singleOrNull() + check(header("Range") == "bytes=2-5" && header("If-Range") == "\"fixture-range\"") + check(header("User-Agent") == "PublicEmbedFixture" && header("Accept-Encoding") == "identity") + check(listOf("Authorization", "Cookie", "Proxy-Authorization", "X-Api-Key", "If-None-Match").all { header(it) == null }) + check(header("Host") != "wrong.invalid") + } + for (method in listOf("POST", "PUT", "DELETE", "OPTIONS")) { + val response = fetcher.fetch(url, method, requestHeaders) + response.body.close() + check(response.statusCode == 405) + } + check(seen.size == 4) { "Unsupported methods must not be sent" } + } finally { server.stop(0) } + } + test("cross_origin_headers") { + val seen = CopyOnWriteArrayList>>() + val first = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val second = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val origin = "http://127.0.0.1:${first.address.port}" + first.createContext("/") { exchange -> + seen.add(exchange.requestHeaders.toMap()) + if (exchange.requestURI.path == "/entry.js") { + exchange.responseHeaders.add("Location", "http://localhost:${second.address.port}/middle.js") + exchange.sendResponseHeaders(302, -1) + } else { + exchange.sendResponseHeaders(200, 2) + exchange.responseBody.use { it.write("ok".toByteArray()) } + } + exchange.close() + } + second.createContext("/") { exchange -> + seen.add(exchange.requestHeaders.toMap()) + exchange.responseHeaders.add("Location", "$origin/final.js") + exchange.sendResponseHeaders(308, -1) + exchange.close() + } + first.start(); second.start() + try { + val response = RestrictedResourceFetcher().fetch("$origin/entry.js", "GET", mapOf( + "Origin" to origin, "Referer" to "$origin/embed?fixture=public", "Range" to "bytes=0-1", + "Cookie" to "fixture-only", "Authorization" to "fixture-only", "X-Private" to "fixture-only")) + check(response.body.use { String(it.readBytes()) } == "ok") + check(seen.size == 3) + fun header(index: Int, name: String) = seen[index].entries.firstOrNull { it.key.equals(name, true) }?.value?.singleOrNull() + // The JDK suppresses Origin as a restricted header; Android may send it. + check(header(0, "Referer") == "$origin/embed?fixture=public") { "Initial public Referer missing: ${header(0, "Referer")}" } + for (index in 1..2) check(header(index, "Origin") == null && header(index, "Referer") == null) + for (index in 0..2) { + check(header(index, "Range") == "bytes=0-1") + check(listOf("Cookie", "Authorization", "X-Private").all { header(index, it) == null }) + } + } finally { first.stop(0); second.stop(0) } + } + test("tls_downgrade_and_stream_ownership") { + val opened = mutableListOf() + val fetcher = RestrictedResourceFetcher(openConnection = { url -> + FixtureConnection(url, 302, mapOf("Location" to listOf("http://media.example.org/video.mp4"))).also { opened.add(it) } + }) + val downgrade = fetcher.fetch("https://media.example.org/entry") + downgrade.body.close() + check(downgrade.statusCode == 403 && opened.size == 1 && opened.single().closed) + val mixed = fetcher.fetch("http://media.example.org/video.mp4", secureContext = true) + mixed.body.close() + check(mixed.statusCode == 403 && opened.size == 1) + val tlsConnection = FixtureConnection(URL("https://media.example.org/video.mp4"), failure = SSLHandshakeException("fixture untrusted peer")) + val tls = RestrictedResourceFetcher(openConnection = { tlsConnection }).fetch(tlsConnection.url.toString()) + tls.body.close() + check(tls.statusCode == 502 && tlsConnection.closed && tlsConnection.statusReads == 1) + var reads = 0 + var closes = 0 + val payload = object : InputStream() { + override fun read(): Int { reads++; return if (reads == 1) 65 else -1 } + override fun close() { closes++ } + } + val connection = FixtureConnection(URL("https://media.example.org/video.mp4"), payload = payload) + val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString()) + check(response.statusCode == 200 && reads == 0 && !connection.closed) { "Body was buffered or closed before streaming" } + check(response.reasonPhrase.isNotBlank()) + check(response.body.read() == 65) + response.body.close() + check(connection.closed && closes == 1) + } + test("android_status_and_transport_failures") { + val invalidPhrase = FixtureConnection(URL("https://media.example.org/player.js"), phrase = "OK\r\nfixture\u00e9") + val safePhrase = RestrictedResourceFetcher(openConnection = { invalidPhrase }).fetch(invalidPhrase.url.toString()) + safePhrase.body.close() + check(safePhrase.reasonPhrase.isNotBlank() && safePhrase.reasonPhrase.all { it.code in 32..126 }) + // WebResourceResponse accepts 100..299/400..599, never 3xx or invalid codes. + for (code in listOf(-1, 99, 300, 304, 305, 306, 399, 600)) { + val connection = FixtureConnection(URL("https://media.example.org/player.js"), code) + val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString()) + response.body.close() + check(response.statusCode == 502 && connection.closed) { "Invalid WebView status escaped: $code -> ${response.statusCode}" } + } + for (failure in listOf(IOException("fixture connection failure"), java.net.SocketTimeoutException("fixture timeout"))) { + val connection = FixtureConnection(URL("https://media.example.org/player.js"), failure = failure) + val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString()) + check(response.statusCode == 502 && response.body.use { it.read() } == -1 && connection.closed) + } + } + test("access_denial_passthrough") { + val hits = CopyOnWriteArrayList() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + hits.add(exchange.requestURI.path) + val code = exchange.requestURI.path.drop(1).toInt() + val bytes = "fixture-denied-$code".toByteArray() + exchange.responseHeaders.add("Content-Type", "text/plain; charset=UTF-8") + exchange.responseHeaders.add("WWW-Authenticate", "Basic realm=fixture") + exchange.sendResponseHeaders(code, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + exchange.close() + } + server.start() + try { + for (code in listOf(401, 403, 451, 404, 416)) { + val response = RestrictedResourceFetcher().fetch("http://127.0.0.1:${server.address.port}/$code") + check(response.statusCode == code && response.mimeType == "text/plain" && response.encoding == "UTF-8") + check(response.body.use { String(it.readBytes()) } == "fixture-denied-$code") + } + check(hits == listOf("/401", "/403", "/451", "/404", "/416")) { "Access denials must not cause retries: $hits" } + } finally { server.stop(0) } + } + test("excluded_redirect_destinations") { + val hits = CopyOnWriteArrayList() + var destination = "" + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + hits.add(exchange.requestURI.path) + exchange.responseHeaders.add("Location", destination) + exchange.sendResponseHeaders(302, -1) + exchange.close() + } + server.start() + try { + val base = "http://127.0.0.1:${server.address.port}" + for (target in listOf("/news/script.js", "/support/help", "/updates/latest", "/setup.apk", + "/melodybox/embed", "/%2575pdater/latest", "https://t.me/fixture", "/live/channel.m3u8", + "/channels/example", "/public-playlists/items", "/public_playlists/items", "/?action=showlist")) { + hits.clear() + destination = if (target.startsWith("/")) "$base$target" else target + for (method in listOf("GET", "HEAD")) { + val response = RestrictedResourceFetcher().fetch("$base/entry.js", method) + response.body.close() + check(response.statusCode == 403) { "Excluded redirect permitted: $target ($method)" } + } + check(hits == listOf("/entry.js", "/entry.js")) { "Excluded target contacted: $target -> $hits" } + } + } finally { server.stop(0) } + } + check(passed > 0) { "Unknown case: $selected" } + println("Restricted resource fetch: $passed cases passed") +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..419fa59 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,31 @@ +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) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") + // Beta packaging on the constrained host skips automatic release-lint + // tasks in dependencies too. Standalone lintRelease remains a separate gate. + tasks.configureEach { + if (name.contains("lintVital", ignoreCase = true)) { + enabled = false + } + } +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..443482d --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx512m -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=48m -XX:+UseSerialGC -XX:+ExitOnOutOfMemoryError +org.gradle.daemon=false +org.gradle.parallel=false +org.gradle.workers.max=1 +kotlin.compiler.execution.strategy=in-process +android.useAndroidX=true +android.newDsl=false +android.builtInKotlin=false +disable-abi-filtering=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bdc0141 --- /dev/null +++ b/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-9.3.1-bin.zip diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +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 "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/assets/licenses/LGPL-2.1.txt b/assets/licenses/LGPL-2.1.txt new file mode 100644 index 0000000..f6683e7 --- /dev/null +++ b/assets/licenses/LGPL-2.1.txt @@ -0,0 +1,501 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +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 this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser 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 Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "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 +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY 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 +LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey 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 library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! diff --git a/assets/licenses/LIBVLC-NOTICE.txt b/assets/licenses/LIBVLC-NOTICE.txt new file mode 100644 index 0000000..2bd6843 --- /dev/null +++ b/assets/licenses/LIBVLC-NOTICE.txt @@ -0,0 +1,20 @@ +LibVLC for Android 3.7.7 +Copyright VideoLAN and the LibVLC authors +Published artifact: org.videolan.android:libvlc-all:3.7.7 +Android bindings are licensed under GNU LGPL version 2.1; the license text accompanies this notice. Native components retain their upstream licenses. ReelBox is distributed under GPL version 3. + +The LibVLC binary is unmodified and dynamically linked. ReelBox is an independent app, not VideoLAN VLC for Android or KMPlayer. No VideoLAN artwork or branding is included. + +ReelBox source and Gradle build files permit rebuilding this app and replacing the LibVLC dependency; no signing or installation restriction is imposed on modified builds. Use your own Android signing key for a rebuilt application. + +Pinned upstream source and build instructions: +https://github.com/videolan/vlc-android/tree/libvlc-3.7.7 +Tag commit: 7a7c8f14be6759f9bb1b9a934fd086259473e20e +This upstream build pins libvlcjni commit 0b8dc65efb203c86a0476bc337ddd99ecf1c0ef6: +https://code.videolan.org/videolan/libvlcjni/-/tree/0b8dc65efb203c86a0476bc337ddd99ecf1c0ef6 +The upstream buildsystem/get-vlc.sh pins the native VLC source and the build tree contains contribution recipes and patches. +Published Java sources: +https://repo.maven.apache.org/maven2/org/videolan/android/libvlc-all/3.7.7/libvlc-all-3.7.7-sources.jar + +ReelBox's source distribution includes the published Java sources and exact Maven POM under third_party/libvlc. Its dependency and native build instructions are documented in third_party/libvlc/NOTICE.txt. +This software is provided without warranty. Retain all license and copyright notices when redistributing. diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..c0d1153 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'data/api_service.dart'; +import 'state/app_state.dart'; +import 'ui/catalog.dart'; +import 'ui/library.dart'; + +class MyApp extends StatefulWidget { + const MyApp({super.key, this.state}); + final AppState? state; + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final AppState state = widget.state ?? AppState(api: ApiService()); + @override + void dispose() { + if (widget.state == null) state.dispose(); + super.dispose(); + } + + ThemeData theme(Brightness brightness) { + final dark = brightness == Brightness.dark; + final colors = ColorScheme.fromSeed( + seedColor: const Color(0xFFFF675C), + brightness: brightness, + primary: dark ? const Color(0xFFFF8276) : const Color(0xFFBC3D34), + surface: dark ? const Color(0xFF0B0D12) : const Color(0xFFFAF8F5), + ); + return ThemeData( + useMaterial3: true, + colorScheme: colors, + scaffoldBackgroundColor: colors.surface, + visualDensity: VisualDensity.standard, + appBarTheme: AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: colors.surface, + titleTextStyle: TextStyle( + fontFamily: 'Roboto', + color: colors.onSurface, + fontSize: 23, + fontWeight: FontWeight.w700, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colors.surfaceContainer, + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none, + ), + ), + chipTheme: ChipThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + dividerTheme: DividerThemeData( + color: colors.outlineVariant.withValues(alpha: .45), + space: 1, + ), + navigationBarTheme: NavigationBarThemeData( + height: 74, + backgroundColor: colors.surfaceContainerLow, + indicatorColor: colors.primary.withValues(alpha: .17), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ); + } + + @override + Widget build(BuildContext context) => AppScope( + notifier: state, + child: AnimatedBuilder( + animation: state, + builder: (_, _) => MaterialApp( + title: 'Reelbox', + debugShowCheckedModeBanner: false, + theme: theme(Brightness.light), + darkTheme: theme(Brightness.dark), + themeMode: switch (state.prefs.theme) { + 'light' => ThemeMode.light, + 'system' => ThemeMode.system, + _ => ThemeMode.dark, + }, + locale: Locale(state.locale), + supportedLocales: const [Locale('en'), Locale('fa')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: state.prefs.introCompleted + ? const _AppShell() + : const FirstRunIntroPage(), + ), + ), + ); +} + +class _AppShell extends StatefulWidget { + const _AppShell(); + @override + State<_AppShell> createState() => _AppShellState(); +} + +class _AppShellState extends State<_AppShell> { + int selected = 0; + final List pages = [const DiscoverPage(), null, null, null, null]; + Widget page(int index) => switch (index) { + 0 => const DiscoverPage(), + 1 => const BrowsePage(), + 2 => const SearchPage(), + 3 => const LibraryPage(), + _ => const MorePage(), + }; + @override + Widget build(BuildContext context) { + final revision = AppScope.of(context).apiRevision; + return Scaffold( + body: IndexedStack( + index: selected, + children: [ + for (var i = 0; i < pages.length; i++) + KeyedSubtree( + key: ValueKey(i < 3 ? 'catalog-$i-$revision' : 'local-$i'), + child: pages[i] ?? const SizedBox.shrink(), + ), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: selected, + onDestinationSelected: (index) => setState(() { + selected = index; + pages[index] ??= page(index); + }), + destinations: [ + NavigationDestination( + icon: const Icon(Icons.explore_outlined), + selectedIcon: const Icon(Icons.explore), + label: tr(context, 'Discover', 'کشف'), + ), + NavigationDestination( + icon: const Icon(Icons.grid_view_outlined), + selectedIcon: const Icon(Icons.grid_view_rounded), + label: tr(context, 'Browse', 'مرور'), + ), + NavigationDestination( + icon: const Icon(Icons.search_rounded), + label: tr(context, 'Search', 'جستجو'), + ), + NavigationDestination( + icon: const Icon(Icons.video_library_outlined), + selectedIcon: const Icon(Icons.video_library_rounded), + label: tr(context, 'Library', 'کتابخانه'), + ), + NavigationDestination( + icon: const Icon(Icons.tune_rounded), + label: tr(context, 'More', 'بیشتر'), + ), + ], + ), + ); + } +} diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 0000000..6146692 --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Runs native/local initialization behind a visible, recoverable startup view. +/// Raw exception strings are never rendered: they can include file paths, remote +/// service data or removed vendor text. +class ReelboxBootstrap extends StatefulWidget { + const ReelboxBootstrap({ + super.key, + required this.initialize, + required this.builder, + }); + final Future Function() initialize; + final WidgetBuilder builder; + @override + State createState() => _ReelboxBootstrapState(); +} + +class _ReelboxBootstrapState extends State { + bool _ready = false, _failed = false; + int _generation = 0; + + @override + void initState() { + super.initState(); + unawaited(_start()); + } + + Future _start() async { + final generation = ++_generation; + setState(() { + _ready = false; + _failed = false; + }); + try { + await widget.initialize(); + if (mounted && generation == _generation) setState(() => _ready = true); + } catch (_) { + if (mounted && generation == _generation) setState(() => _failed = true); + } + } + + @override + void dispose() { + _generation++; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_ready) return widget.builder(context); + return MaterialApp( + title: 'Reelbox', + debugShowCheckedModeBanner: false, + theme: ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: const Color(0xFFBC3D34), + ), + home: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.local_movies_outlined, size: 48), + const SizedBox(height: 24), + const Text('Reelbox', style: TextStyle(fontSize: 28)), + const SizedBox(height: 24), + if (!_failed) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 24), + const Text('Opening your library'), + ] else ...[ + const Text( + 'Reelbox could not start', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + const Text( + 'Your library has not been reset. Try starting again.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => unawaited(_start()), + child: const Text('Try again'), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/data/api_service.dart b/lib/data/api_service.dart new file mode 100644 index 0000000..fc8a013 --- /dev/null +++ b/lib/data/api_service.dart @@ -0,0 +1,643 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models.dart'; +import 'content_policy.dart'; + +List _array(dynamic raw) { + if (raw == null) return []; + if (raw is List) return raw; + if (raw is Map) { + for (final key in ['posters', 'data', 'result', 'items']) { + if (raw[key] is List) return raw[key] as List; + } + } + throw const ApiException( + 'The service returned an unsupported response shape.', + ); +} + +List> _maps(dynamic raw) => + _array(raw) + .whereType() + .map((item) => Map.from(item)) + .toList(); +Map _object(dynamic raw) { + if (raw is Map) return Map.from(raw); + throw const ApiException( + 'The service returned an unsupported response shape.', + ); +} + +List _titles(dynamic raw) => + _maps(raw) + .where(ContentPolicy.allowsTitleJson) + .map(TitleRecord.fromJson) + .where((title) => title.id > 0 && title.title.isNotEmpty) + .toList(); + +/// Safe error suitable for presentation. Never holds request URLs or form data. +class ApiException implements Exception { + final String message; + final int? statusCode; + const ApiException(this.message, {this.statusCode}); + @override + String toString() => message; +} + +/// A single raw server page, compatible with existing List-based callers. +/// Visible items may be empty even when the raw page has a continuation. +class CatalogPage extends ListBase { + CatalogPage(Iterable items, {required this.nextPage}) + : _items = List.unmodifiable(items); + final List _items; + final int? nextPage; + @override + int get length => _items.length; + @override + set length(int value) => throw UnsupportedError('Read-only page'); + @override + TitleRecord operator [](int index) => _items[index]; + @override + void operator []=(int index, TitleRecord value) => + throw UnsupportedError('Read-only page'); +} + +class ApiService { + // Iran accepts a public route marker, not an upstream application secret. + static const sharedKey = 'public'; + // Compatibility only; actor queries no longer send a key. + static const actorKey = 'public'; + // Release AOT also uses --obfuscate. This removes an obvious literal from + // binary string scans, not a secret: network destinations remain observable. + static final String iranBaseUrl = String.fromCharCodes( + [ + 50, + 46, + 46, + 42, + 41, + 96, + 117, + 117, + 40, + 63, + 63, + 54, + 56, + 53, + 34, + 116, + 62, + 59, + 40, + 46, + 46, + 35, + 116, + 46, + 53, + 42, + 117, + 59, + 42, + 51, + 117, + ].map((value) => value ^ 0x5a), + ); + static final String defaultBaseUrl = iranBaseUrl; + static const _selectionKey = 'reelbox.api-selection.v1'; + // One production metadata origin; loopback remains an explicit test seam. + static final _serviceOrigins = {Uri.parse(iranBaseUrl).origin}; + static const _legacyHosts = { + 'simbaapp.info', + 'winbedrives.com', + 'boxlimon.com', + 'gitcss.info', + }; + final Dio _dio; + // Only pending idempotent metadata reads are shared. Never retain detail, + // source or catalog responses after completion: existing refreshes stay fresh. + final _pendingMetadata = >{}; + static const _maxPendingMetadata = 64; + + static bool _shareableMetadata(Uri uri) { + if (RegExp( + r'^/api/(first/public/|(genre|country)/all/public/|movie/by/[0-9]+/public/|movie/by/actor/[0-9]+/public/|season/by/serie/[0-9]+/public/|role/by/poster/[0-9]+/public/|subtitles/by/(movie|episode)/[0-9]+/public/|(movie|serie|poster)/by/filtres/[^?]+/public/)$', + ).hasMatch(uri.path)) { + // Unknown query actions can change the semantics of an otherwise known + // route. Keep them outside the sharing allowlist. + return !uri.queryParametersAll.containsKey('action'); + } + return false; + } + final Future Function() _preferences; + bool _cacheRead = false; + Future? _cacheRestore; + static const _cacheKey = 'reelbox.trusted-api.v1'; + String _baseUrl; + int _selectionGeneration = 0; + Future _selectionWrite = Future.value(); + ApiService({ + Dio? dio, + String? baseUrl, + Future Function()? preferences, + }) : _dio = dio ?? Dio(), + _preferences = preferences ?? SharedPreferences.getInstance, + _baseUrl = _validatedBase(baseUrl ?? defaultBaseUrl) { + // Do not inherit upstream credentials or query defaults from injected Dio. + _dio.options = BaseOptions( + connectTimeout: const Duration(seconds: 12), + receiveTimeout: const Duration(seconds: 25), + sendTimeout: const Duration(seconds: 12), + followRedirects: false, + maxRedirects: 0, + responseType: ResponseType.plain, + headers: {'Accept': 'application/json'}, + ); + } + String get baseUrl => _baseUrl; + String get hostRoot => Uri.parse(_baseUrl).origin; + static bool _loopback(Uri uri) => + {'localhost', '127.0.0.1', '::1', '[::1]'}.contains(uri.host); + static String _validatedBase(String value) { + try { + if (value != value.trim() || + !RegExp(r'^https?://[^/]+/api/?$').hasMatch(value)) { + throw const FormatException(); + } + final uri = Uri.parse(value); + final trusted = + uri.scheme == 'https' && _serviceOrigins.contains(uri.origin); + final local = + _loopback(uri) && (uri.scheme == 'http' || uri.scheme == 'https'); + if ((!trusted && !local) || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + !{'/api', '/api/'}.contains(uri.path)) { + throw const FormatException(); + } + return uri.replace(path: '/api/').toString(); + } on FormatException { + throw const ApiException('Use a trusted HTTPS API server.'); + } + } + + Future setBaseUrl(String url) async { + final validated = _validatedBase(url); + ++_selectionGeneration; + final write = _selectionWrite.then((_) async { + final local = _loopback(Uri.parse(validated)); + if (!local) { + final stored = await (await _preferences()).setString( + _selectionKey, + 'primary', + ); + if (!stored) { + throw const ApiException('Could not save the API selection.'); + } + } + _cacheRead = true; + _baseUrl = validated; + }); + _selectionWrite = write.catchError((Object _) {}); + return write; + } + + // `discover` remains source-compatible; remote provider discovery is retired. + Future initialize({bool discover = true}) async { + await _restoreCachedBase(); + } + + Future _restoreCachedBase() { + if (_cacheRead || _loopback(Uri.parse(_baseUrl))) { + return Future.value(); + } + final running = _cacheRestore; + if (running != null) return running; + late final Future work; + work = + (() async { + final generation = _selectionGeneration; + try { + final prefs = await _preferences(); + if (_selectionGeneration != generation) return; + // Stored provider choices and old discovery caches are migration + // inputs, never permission to contact another production origin. + await prefs.setString(_selectionKey, 'primary'); + if (prefs.containsKey(_cacheKey)) { + await prefs.remove(_cacheKey); + } + } catch (_) { + // Storage failure must not change the Iran-only in-memory default. + } + })().whenComplete(() { + _cacheRead = true; + if (identical(_cacheRestore, work)) _cacheRestore = null; + }); + _cacheRestore = work; + return work; + } + + Uri _resolve(String path, {bool allowLegacy = true}) { + try { + if (path != path.trim() || path.startsWith('//')) { + throw const FormatException(); + } + var rawPath = path.split('?').first; + for (var i = 0; i < 3; i++) { + if (RegExp(r'[\\\x00-\x20\x7f]').hasMatch(rawPath) || + RegExp(r'(^|/)\.{1,2}(/|$)').hasMatch(rawPath)) { + throw const FormatException(); + } + final decoded = Uri.decodeComponent(rawPath); + if (decoded == rawPath) break; + rawPath = decoded; + } + var uri = Uri.parse(path); + if (uri.hasFragment || uri.userInfo.isNotEmpty) { + throw const FormatException(); + } + final base = Uri.parse(_baseUrl); + if (uri.hasScheme || uri.hasAuthority) { + if (!{'http', 'https'}.contains(uri.scheme)) { + throw const FormatException(); + } + if (uri.origin == base.origin) { + // Current-host requests never downgrade HTTPS. + } else if (allowLegacy && + _legacyHosts.contains(uri.host) && + (uri.scheme == 'http' || uri.scheme == 'https') && + ((!uri.hasPort) || uri.port == 80 || uri.port == 443) && + (uri.path.startsWith('/api-user/newapi/') || + uri.path.startsWith('/api/'))) { + uri = Uri.parse( + '$hostRoot${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}', + ); + } else { + throw const FormatException(); + } + } else { + uri = base.resolve(path); + } + if (uri.origin != base.origin || + !(uri.path.startsWith('/api/') || + uri.path.startsWith('/api-user/'))) { + throw const FormatException(); + } + return uri; + } on FormatException { + throw const ApiException('The service link is not trusted.'); + } + } + + List decodeTitles(dynamic raw) => _titles(raw); + Future _legacy(String file, Map query) async { + await _restoreCachedBase(); + return _request( + 'GET', + _resolve('$hostRoot/api-user/newapi/$file'), + query: query, + ); + } + + Future> _titlePage( + Future request, + int page, + ) async { + final raw = _array(await request); + // Hidden announcements must not terminate upstream catalog pagination. + return CatalogPage(_titles(raw), nextPage: raw.isEmpty ? null : page + 1); + } + + Future> search( + String query, { + int page = 1, + bool english = false, + }) => _titlePage( + _legacy('like.php', { + 'action': english ? 'search-movie-en' : 'search-movie', + 'q': query, + 'pageno': page, + }), + page, + ); + Future> recommended(int genre, {int page = 1}) async => + _titlePage( + _legacy('filter.php', { + 'action': 'pishnahad', + 'garid': genre, + 'pageno': page, + }), + page, + ); + Future> bestRandom({bool byYear = false, int page = 1}) => + _titlePage( + _legacy('like.php', { + 'action': byYear ? 'best-rand-year' : 'best-rand-top', + 'pageno': page, + }), + page, + ); + Future> trending() async { + final raw = _array( + await _legacy('like.php', {'action': 'search-trand', 'pageno': 1}), + ); + return raw + .map( + (item) => item is String + ? item + : item is Map + ? item['title'] ?? item['q'] + : null, + ) + .whereType() + .where(ContentPolicy.allowsText) + .where((title) => title.trim().isNotEmpty) + .take(14) + .toList(); + } + + Future>> actors({ + int page = 1, + String search = '', + }) async { + await _restoreCachedBase(); + if (search.trim().isEmpty) { + return _maps( + ContentPolicy.sanitizeJson( + await _legacy('like.php', {'action': 'all-actor', 'pageno': page}), + ), + ); + } + // Native Retrofit evidence; the desktop like.php starsearch route currently + // returns an empty non-JSON body rather than actor results. + return _maps( + await get( + '$hostRoot/api-user/filmahang/star.php', + query: {'action': 'starsearch', 'q': search.trim(), 'pageno': page}, + ), + ); + } + + Future>> playlists({ + bool mine = false, + String userId = '', + int page = 1, + }) async { + // No-network compatibility seam for older callers. Public lists are removed. + if (mine) { + throw const ApiException('Server playlists are not part of Reelbox.'); + } + return const []; + } + + Future>> playlistItems( + int listId, { + int page = 1, + }) async => const []; + Future>> curatedLinks({bool best = false}) async { + await _restoreCachedBase(); + final groups = _maps( + await _legacy(best ? 'bartar.php' : 'pishnahad.php', {}), + ); + final result = >[]; + for (final group in groups) { + final titles = group['title'], links = group['link']; + if (titles is! List || links is! List) continue; + for (var i = 0; i < titles.length && i < links.length; i++) { + if (titles[i] is! String || links[i] is! String) continue; + if (!ContentPolicy.allowsText(titles[i] as String) || + !ContentPolicy.allowsUrl(links[i] as String)) { + continue; + } + try { + result.add({ + 'title': titles[i], + 'link': _resolve(links[i] as String).toString(), + }); + } on ApiException { + // Never expose a server-supplied arbitrary destination as API content. + } + } + } + return result; + } + + Future> curatedContent(String link, {int page = 1}) async { + await _restoreCachedBase(); + final uri = _resolve(link); + final params = {...uri.queryParameters, 'pageno': '$page'}; + return _titles(await get(uri.replace(queryParameters: params).toString())); + } + + Future> home() async { + final data = _object(await get('first/$sharedKey/')); + // Unknown future server fields cannot silently introduce vendor feeds. + const keys = { + 'posters', + 'genres', + 'slides', + 'actors', + 'countries', + 'movies', + 'series', + 'featured', + 'popular', + 'trending', + }; + return { + for (final entry in data.entries) + if (keys.contains(entry.key)) entry.key: entry.value, + }; + } + + Future> _tags(String name) async => + _maps(await get('$name/all/$sharedKey/')).map(Tag.fromJson).toList(); + Future> genres() => _tags('genre'); + Future> countries() => _tags('country'); + Future> categories() async => const []; + Future> catalog({ + String kind = 'movie', + int genre = 0, + int country = 0, + String order = 'created', + int page = 0, + }) async { + if (!{'movie', 'serie', 'series', 'mixed', 'poster'}.contains(kind) || + page < 0) { + throw const ApiException('Unsupported catalog filter.'); + } + await _restoreCachedBase(); + final normalized = kind == 'series' ? 'serie' : kind; + final mixed = + normalized == 'mixed' || normalized == 'poster' || country != 0; + final route = mixed + ? 'poster/by/filtres/$genre/$country/${Uri.encodeComponent(order)}/$page' + : '$normalized/by/filtres/$genre/${Uri.encodeComponent(order)}/$page'; + final raw = _array(await _request('GET', _resolve('$route/$sharedKey/'))); + final titles = _titles(raw); + return CatalogPage( + country != 0 && {'movie', 'serie'}.contains(normalized) + ? titles.where((title) => title.type == normalized) + : titles, + nextPage: raw.isEmpty ? null : page + 1, + ); + } + + Future> channels({ + int category = 0, + int country = 0, + int page = 0, + }) async => const []; + Future poster(int id) async => + TitleRecord.fromJson(_object(await get('movie/by/$id/$sharedKey/'))); + Future channel(int id) async => + throw const ApiException('This content is not part of Reelbox.'); + Future> seasons(int id) async => + _maps(await get('season/by/serie/$id/$sharedKey/')) + .map(SeasonRecord.fromJson) + .toList(); + Future> actorTitles(int id) async => + _titles(await get('movie/by/actor/$id/$sharedKey/')); + Future>> roles(int posterId) async => + _maps(await get('role/by/poster/$posterId/$sharedKey/')); + Future>> subtitles(String kind, int id) async { + if (!{'movie', 'episode'}.contains(kind)) { + throw const ApiException('Unsupported subtitle kind.'); + } + return _maps(await get('subtitles/by/$kind/$id/$sharedKey/')); + } + + Future>> comments(int posterId) async => const []; + + Future get(String path, {Map? query}) async { + await _restoreCachedBase(); + return ContentPolicy.sanitizeJson( + await _request('GET', _resolve(path), query: query), + ); + } + + Future post(String path, Map form) async { + await _restoreCachedBase(); + return _request('POST', _resolve(path, allowLegacy: false), form: form); + } + + Future _request( + String method, + Uri uri, { + Map? query, + Map? form, + bool retryTransport = true, + }) async { + final policyUris = [ + uri, + for (final action in [query?['action'], form?['action']]) + if (action != null) uri.replace(queryParameters: {'action': '$action'}), + ]; + if (policyUris.any((value) => !ContentPolicy.allowsApiPath(value)) || + !ContentPolicy.allowsUrl(uri.toString())) { + throw const ApiException('This content is not part of Reelbox.'); + } + // Key exactly the URI Dio will send, including origin, page and filters. + // Validation above always runs, including for a potential cache hit. + final requestUri = Options(method: method) + .compose(_dio.options, uri.toString(), queryParameters: query) + .uri; + if (method != 'GET' || !_shareableMetadata(requestUri)) { + return _sendRequest(method, requestUri, form: form, + retryTransport: retryTransport); + } + final key = requestUri.toString(); + final pending = _pendingMetadata[key]; + if (pending != null) return pending; + if (_pendingMetadata.length >= _maxPendingMetadata) { + return _sendRequest(method, requestUri, + retryTransport: retryTransport); + } + late final Future work; + work = _sendRequest(method, requestUri, + retryTransport: retryTransport).whenComplete(() { + if (identical(_pendingMetadata[key], work)) { + _pendingMetadata.remove(key); + } + }); + _pendingMetadata[key] = work; + return work; + } + + Future _sendRequest( + String method, + Uri uri, { + Map? form, + required bool retryTransport, + }) async { + for (var attempt = 0; ; attempt++) { + try { + final response = await _dio.request( + uri.toString(), + data: form, + options: Options( + method: method, + followRedirects: false, + maxRedirects: 0, + responseType: ResponseType.plain, + contentType: form == null + ? null + : Headers.formUrlEncodedContentType, + ), + ); + final status = response.statusCode ?? 0; + if (status < 200 || status >= 300) { + throw ApiException( + 'The service rejected the request.', + statusCode: status, + ); + } + if (response.data is String) { + final text = (response.data as String) + .replaceFirst('\uFEFF', '') + .trim(); + return jsonDecode(text); + } + return response.data; + } on DioException catch (error) { + if (attempt == 0 && + retryTransport && + method == 'GET' && + error.response == null && + const { + DioExceptionType.connectionError, + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + }.contains(error.type)) { + // One replay of an idempotent read only. No discovery request storm, + // alternate untrusted destination, denial retry or mutation replay. + await _restoreCachedBase(); + continue; + } + final code = error.response?.statusCode; + final message = switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + 'The service timed out. Please retry.', + DioExceptionType.connectionError => + 'Unable to connect. Check your connection and retry.', + DioExceptionType.cancel => 'The request was cancelled.', + _ when code == 401 || code == 403 => + 'The service denied access to this content.', + _ => 'The service request failed. Please retry.', + }; + throw ApiException(message, statusCode: code); + } on FormatException { + throw const ApiException('The service returned an invalid response.'); + } + } + } +} diff --git a/lib/data/catalog_discovery.dart b/lib/data/catalog_discovery.dart new file mode 100644 index 0000000..ba07140 --- /dev/null +++ b/lib/data/catalog_discovery.dart @@ -0,0 +1,191 @@ +import 'dart:collection'; + +import 'api_service.dart'; +import 'content_policy.dart'; +import 'models.dart'; + +/// Iran read-only discovery contracts, independent of account APIs. +class CatalogDiscovery { + CatalogDiscovery(this.api); + final ApiService api; + + Future _legacy(String file, Map query) => + api.get('${api.hostRoot}/api-user/$file', query: query); + + Future suggestions({int page = 1}) async => + _preservePage(await api.bestRandom(page: page), page); + + Future search( + String query, { + int page = 1, + bool english = false, + DiscoverySearchFilter filter = const DiscoverySearchFilter(), + }) async { + final result = _preservePage( + await api.search(query, page: page, english: english), + page, + ); + return CatalogPage(result.where(filter.accepts), nextPage: result.nextPage); + } + + CatalogPage _preservePage(List rows, int page) => CatalogPage( + rows, + nextPage: rows is CatalogPage + ? rows.nextPage + : rows.isEmpty + ? null + : page + 1, + ); + + Future directory( + DiscoveryDirectory kind, { + int page = 1, + }) async { + final themes = kind == DiscoveryDirectory.themes; + final raw = discoveryRows( + await _legacy(themes ? 'filmahang/star.php' : 'newapi/like.php', { + 'action': themes ? 'mazmon' : 'colection', + 'pageno': page, + }), + ); + return DiscoveryObjectPage( + raw.map((row) => {...row, 'title': row['title'] ?? row['mazmon'] ?? ''}), + nextPage: themes || raw.isEmpty ? null : page + 1, + ); + } + + /// These endpoints return poster identifiers, not playable poster objects. + Future sharedTitles({int page = 1, bool newest = false}) async { + final rows = discoveryRows( + await _legacy('filmahang/star.php', { + 'action': newest ? 'new' : 'share', + 'pageno': page, + }), + ); + final titles = []; + final ids = {}; + // Sequential hydration bounds server load on the account-free public API. + for (final row in rows) { + final id = int.tryParse('${row['id']}') ?? 0; + if (id > 0 && ids.add(id)) titles.add(await api.poster(id)); + } + return CatalogPage(titles, nextPage: rows.isEmpty ? null : page + 1); + } +} + +enum DiscoveryDirectory { themes, franchises } + +class DiscoveryObjectPage extends ListBase> { + DiscoveryObjectPage( + Iterable> rows, { + required this.nextPage, + }) : _rows = List.unmodifiable(rows); + final List> _rows; + final int? nextPage; + @override + int get length => _rows.length; + @override + set length(int value) => throw UnsupportedError('Read-only page'); + @override + Map operator [](int index) => _rows[index]; + @override + void operator []=(int index, Map value) => + throw UnsupportedError('Read-only page'); +} + +/// The PHP search API has no evidenced advanced-filter parameters. Apply these +/// explicitly to each returned page, retaining its unfiltered continuation. +class DiscoverySearchFilter { + const DiscoverySearchFilter({ + this.kind = 'mixed', + this.minimumYear = 0, + this.minimumRating = 0, + this.genre = 0, + this.country = 0, + }); + final String kind; + final int minimumYear, genre, country; + final double minimumRating; + bool accepts(TitleRecord title) => + (kind == 'mixed' || + (kind == 'serie' ? title.isSeries : title.type == kind)) && + (minimumYear == 0 || title.year >= minimumYear) && + title.imdb >= minimumRating && + (genre == 0 || title.genres.any((tag) => tag.id == genre)) && + (country == 0 || title.countries.any((tag) => tag.id == country)); + String get key => '$kind:$minimumYear:$minimumRating:$genre:$country'; +} + +/// A complete actor endpoint is unpaged. Only an empty/unavailable endpoint +/// switches to the observed actor-name search, whose raw pages remain paged. +class ActorFilmography { + ActorFilmography(this.discovery, {required this.id, required this.name}); + final CatalogDiscovery discovery; + final int id; + final String name; + bool usingNameFallback = false; + Future load(int page) async { + if (page == 1 && id > 0 && !usingNameFallback) { + try { + final titles = await discovery.api.actorTitles(id); + if (titles.isNotEmpty) return CatalogPage(titles, nextPage: null); + } on ApiException catch (error) { + if (error.statusCode == 401 || error.statusCode == 403) rethrow; + } + } + usingNameFallback = true; + if (name.trim().isEmpty) return CatalogPage([], nextPage: null); + return discovery.search(name, page: page); + } +} + +Map actorMetadata(Map row) { + final nested = row['actor']; + final person = nested is Map ? Map.from(nested) : row; + return { + ...person, + 'id': person['star_id'] ?? person['actor_id'] ?? person['id'] ?? 0, + 'name': + person['star_name'] ?? + person['actor_name'] ?? + person['name'] ?? + person['title'] ?? + '', + 'image': person['image'] ?? person['img'] ?? person['photo'] ?? '', + 'role': row['role'] ?? person['role'] ?? '', + 'bio': person['bio'] ?? person['biography'] ?? person['description'] ?? '', + }; +} + +MediaSource? currentHistorySource( + List sources, + String previousUrl, +) { + final playable = sources.where((source) { + final uri = Uri.tryParse(source.url); + return !source.isTrailer && + ContentPolicy.allowsUrl(source.url) && + uri != null && + {'https', 'http'}.contains(uri.scheme) && + uri.host.isNotEmpty; + }).toList(); + return playable.where((source) => source.url == previousUrl).firstOrNull ?? + playable.firstOrNull; +} + +List> discoveryRows(dynamic raw) { + if (raw is Map) { + for (final key in ['posters', 'data', 'result', 'items']) { + if (raw[key] is List) return discoveryRows(raw[key]); + } + } + if (raw is! List) { + throw const ApiException( + 'The service returned an unsupported response shape.', + ); + } + return raw + .whereType() + .map((row) => Map.from(row)) + .toList(); +} diff --git a/lib/data/content_policy.dart b/lib/data/content_policy.dart new file mode 100644 index 0000000..b930152 --- /dev/null +++ b/lib/data/content_policy.dart @@ -0,0 +1,305 @@ +/// A presentation boundary for the independent, film/series-only client. +/// +/// Catalog/media hosts are not blocked merely because the original service owns +/// them. The removed vendor destinations, announcements and navigation families +/// are rejected separately. This also applies to imported/previously saved data. +class ContentPolicy { + static const _removedKeys = { + 'comment', + 'comments', + 'replies', + 'channel', + 'channels', + 'livechannel', + 'livechannels', + 'live_channels', + 'publicplaylist', + 'publicplaylists', + 'public_playlists', + 'playlists', + 'news', + 'news_url', + 'announcements', + 'announcement', + 'notifications', + 'notification', + 'update', + 'updates', + 'appupdate', + 'app_update', + 'app_version', + 'latest_version', + 'new_version', + 'version_code', + 'apk', + 'apk_url', + 'download_apk', + 'support', + 'support_url', + 'supportlink', + 'telegram', + 'telegram_url', + 'telegram_channel', + 'telegramaccount', + 'ads', + 'advertisements', + 'advertisement', + 'admob', + 'shop', + 'shop_url', + 'store_url', + 'melodybox', + 'norton', + }; + static const _removedTypes = { + 'channel', + 'live', + 'live_channel', + 'news', + 'announcement', + 'advertisement', + 'ad', + 'app', + 'apk', + 'update', + 'support', + 'public_playlist', + }; + static final _formatting = RegExp( + r'[\u064b-\u065f\u0670\u06d6-\u06ed\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]', + ); + static final _urls = RegExp( + r'''(?:https?://|tg://|telegram://|www\.)[^\s<>"')]+''', + caseSensitive: false, + ); + static final _handles = RegExp(r'(^|[\s>(])@[a-zA-Z][a-zA-Z0-9_]{3,}'); + + static String _decode(String value) { + var result = value; + for (var i = 0; i < 3; i++) { + final decoded = result.replaceAllMapped(RegExp(r'(?:%[0-9a-fA-F]{2})+'), ( + match, + ) { + try { + return Uri.decodeComponent(match.group(0)!); + } on FormatException { + return match.group(0)!; + } on ArgumentError { + return match.group(0)!; + } + }); + if (decoded == result) break; + result = decoded; + } + return result + .replaceAllMapped( + RegExp(r'&#(x[0-9a-f]+|[0-9]+);', caseSensitive: false), + (match) { + final entity = match.group(1)!; + final code = int.tryParse( + entity.startsWith(RegExp('[xX]')) ? entity.substring(1) : entity, + radix: entity.startsWith(RegExp('[xX]')) ? 16 : 10, + ); + return code != null && code >= 0 && code <= 0x10ffff + ? String.fromCharCode(code) + : ''; + }, + ) + .replaceAll('&', '&') + .replaceAll(' ', ' '); + } + + static String _normal(String value) => + _decode(value) + .toLowerCase() + .replaceAll('ي', 'ی') + .replaceAll('ى', 'ی') + .replaceAll('ك', 'ک') + .replaceAll(_formatting, ''); + static String _compact(String value) => + _normal(value).replaceAll(RegExp(r'[\s_\-\u0640]+'), ''); + static bool _brand(String value) { + final compact = _compact(value); + return compact.contains('ملودیباکس') || + compact.contains('ملودیبکس') || + compact.contains('melodybox') || + compact.contains('melodibox') || + compact.contains('نورتون') || + compact.contains('نورتن') || + compact.contains('norton'); + } + + static bool allowsText(String value) { + final normal = _normal(value); + final compact = _compact(value); + if (_brand(value) || + normal.contains('تلگرام') || + RegExp(r'\btelegram\b').hasMatch(normal) || + _handles.hasMatch(normal)) { + return false; + } + if (RegExp( + r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)', + dotAll: true, + ).hasMatch(normal) || + RegExp( + r'(آپدیت|بروزرسانی|بهروز رسانی|بهروزرسانی).{0,20}(برنامه|اپلیکیشن|سیمبا)', + dotAll: true, + ).hasMatch(normal) || + RegExp( + r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)', + dotAll: true, + ).hasMatch(normal)) { + return false; + } + if ((compact.contains('originalapp') || + compact.contains('moviebox') || + compact.contains('سیمبا')) && + (compact.contains('support') || + compact.contains('news') || + compact.contains('پشتیبانی'))) { + return false; + } + return _urls + .allMatches(_decode(value)) + .every((match) => allowsUrl(match.group(0)!)); + } + + static String sanitizeText(String value) => + allowsText(value) ? value.replaceAll(_formatting, '').trim() : ''; + + static bool allowsUrl(String value) { + if (value.isEmpty) return true; + if (value != value.trim() || RegExp(r'[\x00-\x20\x7f]').hasMatch(value)) { + return false; + } + final decoded = _decode(value); + if (_brand(decoded) || decoded.contains('\\')) return false; + try { + final uri = Uri.parse( + decoded.startsWith('www.') ? 'https://$decoded' : decoded, + ); + if (uri.userInfo.isNotEmpty || + (uri.hasScheme && + !{'http', 'https'}.contains(uri.scheme.toLowerCase()))) { + return false; + } + final host = uri.host.toLowerCase(); + if ([ + 't.me', + 'telegram.me', + 'telegram.dog', + 'telegram.org', + 'telegra.ph', + ].any((blocked) => host == blocked || host.endsWith('.$blocked'))) { + return false; + } + final path = _normal(uri.path); + if (RegExp(r'\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)(/|$)') + .hasMatch(path)) { + return false; + } + if (RegExp( + r'(^|/)(original[-_]?app|moviebox|simba)/(support|news|updates?|downloads?)(/|$)', + ).hasMatch(path)) { + return false; + } + if (RegExp( + r'(^|/)(app[-_]?updates?|download[-_]?apk|latest[-_]?version)(/|$)', + ).hasMatch(path)) { + return false; + } + return true; + } on FormatException { + return false; + } + } + + static bool allowsApiPath(Uri uri) { + final path = _normal(uri.path); + final actions = (uri.queryParametersAll['action'] ?? const []).map( + _normal, + ); + if (RegExp( + r'(^|/)(comments?|replies|reply|channels?|public[-_]?playlists?|news|updates?|support)([-_][a-z0-9_-]+)?(\.php|/|$)', + ).hasMatch(path)) { + return false; + } + return actions.every( + (action) => + !{ + 'comment', + 'comments', + 'add-comment', + 'reply', + 'replies', + 'pasokh', + 'showlist', + 'show-list_fav', + 'public-playlists', + 'playlist-items', + 'app-update', + 'app-news', + 'telegram', + 'support', + }.contains(action) && + !RegExp(r'(^|[-_])(comments?|replies|reply|pasokh)([-_]|$)') + .hasMatch(action), + ); + } + + static bool _removedRecord(Map value) { + final type = _normal('${value['type'] ?? value['kind'] ?? ''}'); + if (_removedTypes.contains(type)) return true; + for (final key in [ + 'title', + 'name', + 'description', + 'note', + 'message', + 'text', + 'content', + 'body', + 'summary', + 'label', + ]) { + final text = value[key]; + if (text is String && !allowsText(text)) return true; + } + for (final key in [ + 'link', + 'url', + 'website', + 'button_url', + 'download_url', + 'apk_url', + ]) { + final url = value[key]; + if (url is String && !allowsUrl(url)) return true; + } + return false; + } + + static bool allowsTitleJson(Map json) => + !_removedRecord(json); + + /// Returns a fresh JSON tree; removed records/fields never become blank cards. + static dynamic sanitizeJson(dynamic value) { + if (value is Map) { + final map = Map.from(value); + if (_removedRecord(map)) return null; + final result = {}; + for (final entry in map.entries) { + if (_removedKeys.contains(_normal(entry.key))) continue; + final item = sanitizeJson(entry.value); + if (item != null) result[entry.key] = item; + } + return result; + } + if (value is List) { + return value.map(sanitizeJson).where((v) => v != null).toList(); + } + if (value is String) return allowsText(value) ? value : null; + return value; + } +} diff --git a/lib/data/home_banners.dart b/lib/data/home_banners.dart new file mode 100644 index 0000000..38f4223 --- /dev/null +++ b/lib/data/home_banners.dart @@ -0,0 +1,132 @@ +import 'api_service.dart'; +import 'content_policy.dart'; + +class HomeBanner { + const HomeBanner._({ + required this.titleEn, + required this.titleFa, + required this.subtitleEn, + required this.subtitleFa, + required this.target, + required this.isAdvertisement, + this.image, + }); + + final String titleEn, titleFa, subtitleEn, subtitleFa; + final Uri target; + final Uri? image; + final bool isAdvertisement; + String title(bool persian) => persian ? titleFa : titleEn; + String subtitle(bool persian) => persian ? subtitleFa : subtitleEn; + + static final telegram = HomeBanner._( + titleEn: 'ReelBox updates', + titleFa: 'تازه‌های ریل‌باکس', + subtitleEn: 'Updates and announcements', + subtitleFa: 'به‌روزرسانی‌ها و اطلاعیه‌ها', + target: Uri.parse('https://t.me/ReelBoxApp'), + isAdvertisement: false, + ); +} + +/// Only configured advertisements are parsed. The official Telegram entry is +/// fixed in the client and can never be replaced or relabelled by server data. +List parseHomeBanners(dynamic raw) { + final result = [HomeBanner.telegram]; + if (raw is! Map || raw['banners'] is! List) return result; + for (final row in (raw['banners'] as List).take(20)) { + if (result.length == 6) break; + if (row is! Map) continue; + final en = _label(row['title_en'], 80); + final fa = _label(row['title_fa'] ?? row['title_en'], 80); + final subtitleEn = _label(row['subtitle_en'] ?? '', 180); + final subtitleFa = _label( + row['subtitle_fa'] ?? row['subtitle_en'] ?? '', + 180, + ); + final target = _https(row['target_url']); + final imageValue = row['image_url']; + final image = imageValue == null || imageValue == '' + ? null + : _https(imageValue); + if (en == null || + en.isEmpty || + fa == null || + fa.isEmpty || + subtitleEn == null || + subtitleFa == null || + target == null || + (imageValue != null && imageValue != '' && image == null)) { + continue; + } + result.add( + HomeBanner._( + titleEn: en, + titleFa: fa, + subtitleEn: subtitleEn, + subtitleFa: subtitleFa, + target: target, + image: image, + isAdvertisement: true, + ), + ); + } + return List.unmodifiable(result); +} + +String? _label(dynamic value, int limit) { + if (value is! String || + value.length > limit || + RegExp(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]').hasMatch(value) || + !ContentPolicy.allowsText(value)) { + return null; + } + return value.trim(); +} + +Uri? _https(dynamic value) { + if (value is! String || + value.length > 2048 || + RegExp(r'[\x00-\x20\x7f\\]').hasMatch(value) || + RegExp( + r'%(?![0-9a-fA-F]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)', + caseSensitive: false, + ).hasMatch(value) || + !ContentPolicy.allowsUrl(value)) { + return null; + } + try { + final uri = Uri.parse(value); + if (uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.port != 443 || + uri.host.length > 253 || + !uri.host + .split('.') + .every( + (part) => + RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$') + .hasMatch(part), + )) { + return null; + } + return uri; + } on FormatException { + return null; + } +} + +/// Uses only the existing trusted metadata transport, never a config URL. +class HomeBannerService { + const HomeBannerService(this.api); + final ApiService api; + Future> load() async { + try { + return parseHomeBanners(await api.get('banners/')); + } catch (_) { + // Announcements remain reachable when the metadata service is offline. + return [HomeBanner.telegram]; + } + } +} diff --git a/lib/data/models.dart b/lib/data/models.dart new file mode 100644 index 0000000..8b4eb02 --- /dev/null +++ b/lib/data/models.dart @@ -0,0 +1,202 @@ +/// Public service metadata. Source visibility is not an authorization grant. +library; + +import 'content_policy.dart'; + +Map _cleanJson(Map value) { + final clean = ContentPolicy.sanitizeJson(value); + return clean is Map ? Map.from(clean) : {}; +} + +String _text(dynamic value, [String fallback = '']) => + value is String || value is num || value is bool ? '$value' : fallback; +int _integer(dynamic value) { + if (value is int) return value; + if (value is num) return value.isFinite ? value.toInt() : 0; + return int.tryParse(_text(value).trim()) ?? 0; +} + +double _decimal(dynamic value) { + final parsed = value is num + ? value.toDouble() + : double.tryParse(_text(value).trim()); + return parsed != null && parsed.isFinite ? parsed : 0; +} + +List _records(dynamic value, T Function(Map) parse) => + value is List + ? value + .whereType() + .map((item) => parse(Map.from(item))) + .toList() + : []; + +class Tag { + final int id; + final String title, image; + const Tag({this.id = 0, this.title = '', this.image = ''}); + factory Tag.fromJson(Map json) => Tag( + id: _integer(json['id']), + title: ContentPolicy.sanitizeText(_text(json['title'])), + image: ContentPolicy.allowsUrl(_text(json['image'])) + ? _text(json['image']) + : '', + ); + Map toJson() => {'id': id, 'title': title, 'image': image}; +} + +class MediaSource { + final int id; + final String quality, type, url; + const MediaSource({ + this.id = 0, + this.quality = '', + this.type = '', + this.url = '', + }); + factory MediaSource.fromJson(Map json) => MediaSource( + id: _integer(json['id']), + quality: ContentPolicy.sanitizeText(_text(json['quality'])), + type: _text(json['type']), + url: ContentPolicy.allowsUrl(_text(json['url'])) ? _text(json['url']) : '', + ); + bool get isTrailer => + RegExp( + r'تیزر|تریلر|trailer|teaser', + caseSensitive: false, + ).hasMatch(quality) || + type.toLowerCase() == 'trailer'; + Map toJson() => { + 'id': id, + 'quality': quality, + 'type': type, + 'url': url, + }; +} + +class EpisodeRecord { + final int id; + final String title, description, duration; + final List sources; + final Map raw; + const EpisodeRecord({ + this.id = 0, + this.title = '', + this.description = '', + this.duration = '', + this.sources = const [], + this.raw = const {}, + }); + factory EpisodeRecord.fromJson(Map value) { + final json = _cleanJson(value); + return EpisodeRecord( + id: _integer(json['id']), + title: _text(json['title']), + description: _text(json['description']), + duration: _text(json['duration']), + sources: _records(json['sources'], MediaSource.fromJson), + raw: Map.unmodifiable(json), + ); + } + Map toJson() => { + ...raw, + 'id': id, + 'title': title, + 'description': description, + 'duration': duration, + 'sources': sources.map((source) => source.toJson()).toList(), + }; +} + +class SeasonRecord { + final int id; + final String title; + final List episodes; + final Map raw; + const SeasonRecord({ + this.id = 0, + this.title = '', + this.episodes = const [], + this.raw = const {}, + }); + factory SeasonRecord.fromJson(Map value) { + final json = _cleanJson(value); + return SeasonRecord( + id: _integer(json['id']), + title: _text(json['title']), + episodes: _records(json['episodes'], EpisodeRecord.fromJson), + raw: Map.unmodifiable(json), + ); + } + Map toJson() => { + ...raw, + 'id': id, + 'title': title, + 'episodes': episodes.map((episode) => episode.toJson()).toList(), + }; +} + +class TitleRecord { + final int id, year; + final String type, title, description, duration, classification, image, cover; + final double imdb, rating; + final List genres, countries; + final List sources; + final Map raw; + const TitleRecord({ + this.id = 0, + this.type = 'movie', + this.title = '', + this.description = '', + this.duration = '', + this.classification = '', + this.image = '', + this.cover = '', + this.year = 0, + this.imdb = 0, + this.rating = 0, + this.genres = const [], + this.countries = const [], + this.sources = const [], + this.raw = const {}, + }); + factory TitleRecord.fromJson(Map value) { + final json = _cleanJson(value); + return TitleRecord( + id: _integer(json['id'] ?? json['posted_id'] ?? json['movie_id']), + type: _text(json['type'] ?? json['kind'], 'movie'), + title: _text(json['title'] ?? json['name']), + description: _text(json['description']), + duration: _text(json['duration']), + classification: _text(json['classification']), + image: _text(json['image'] ?? json['img'] ?? json['poster']), + cover: _text(json['cover']), + year: _integer(json['year']), + imdb: _decimal(json['imdb']), + rating: _decimal(json['rating']), + genres: _records(json['genres'], Tag.fromJson), + countries: _records(json['country'] ?? json['countries'], Tag.fromJson), + sources: _records(json['sources'], MediaSource.fromJson), + raw: Map.unmodifiable(json), + ); + } + bool get isSeries => type == 'serie' || type == 'series'; + bool get isChannel => type == 'channel'; + Map toJson() => { + ...raw, + 'id': id, + 'type': type, + 'title': title, + 'description': description, + 'duration': duration, + 'classification': classification, + 'image': image, + 'cover': cover, + 'year': year, + 'imdb': imdb, + 'rating': rating, + 'genres': genres.map((tag) => tag.toJson()).toList(), + 'country': countries.map((tag) => tag.toJson()).toList(), + 'sources': sources.map((source) => source.toJson()).toList(), + }; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..88992bc --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; + +import 'app.dart'; +import 'bootstrap.dart'; +import 'data/api_service.dart'; +import 'media/download_service.dart'; +import 'state/app_state.dart'; +export 'app.dart' show MyApp; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + LicenseRegistry.addLicense(() async* { + final notice = await rootBundle.loadString( + 'assets/licenses/LIBVLC-NOTICE.txt', + ); + final license = await rootBundle.loadString('assets/licenses/LGPL-2.1.txt'); + yield LicenseEntryWithLineBreaks([ + 'LibVLC for Android', + ], '$notice\n\n$license'); + }); + final state = AppState(api: ApiService()); + runApp( + ReelboxBootstrap( + initialize: () async { + // Native engine failure is recoverable too, not outside the startup guard. + MediaKit.ensureInitialized(); + await state.initialize(discover: false); + unawaited( + state.api.initialize().catchError((Object _) { + debugPrint( + 'Server discovery unavailable; keeping the trusted API base.', + ); + }), + ); + if (!kIsWeb) { + DownloadService.instance.bindSettings(state); + unawaited( + DownloadService.instance.initialize().catchError((Object _) { + debugPrint('Background downloader initialization unavailable.'); + }), + ); + } + }, + builder: (_) => MyApp(state: state), + ), + ); +} diff --git a/lib/media/cast_media_share.dart b/lib/media/cast_media_share.dart new file mode 100644 index 0000000..dd01b3f --- /dev/null +++ b/lib/media/cast_media_share.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../data/content_policy.dart'; +import 'local_media_server.dart'; +import 'segmented_download.dart'; +import 'subtitle_webvtt.dart'; + +/// Explicit resource ownership, also injectable into the native-session tests. +class CastMediaLease { + CastMediaLease({required Map payload, this._onClose}) + : _payload = Map.unmodifiable(payload); + final Map _payload; + final Future Function()? _onClose; + Future? _closing; + Map get payload => _payload; + Future close() => _closing ??= Future.sync(() async { + await _onClose?.call(); + }); +} + +/// No filesystem or network side effects until an explicit Cast load. +class CastMediaShare { + static Future prepare({ + required Map payload, + String? localFile, + String? subtitleText, + InternetAddress? bindAddress, + String? advertisedHost, + }) async { + final result = Map.from(payload)..remove('subtitleUrl'); + File? selected; + final extraFiles = {}; + if (localFile != null) { + if (!File(localFile).isAbsolute) { + throw ArgumentError('Select an absolute media file.'); + } + selected = File(await File(localFile).resolveSymbolicLinks()); + final stat = await selected.stat(); + if (stat.type != FileSystemEntityType.file || stat.size <= 0) { + throw ArgumentError('Select an existing nonempty media file.'); + } + if (RegExp( + r'\.(m3u8|mpd)$', + caseSensitive: false, + ).hasMatch(selected.path)) { + final directory = selected.parent; + final index = File('${directory.path}/package.json'); + if (!await index.exists() || await index.length() > 16 * 1024 * 1024) { + throw UnsupportedError('Select a complete indexed offline package.'); + } + final id = directory.uri.pathSegments + .where((part) => part.isNotEmpty) + .last; + final downloads = SegmentedDownload(root: directory.parent); + final package = await downloads.load(id); + if (await File(package.path).resolveSymbolicLinks() != selected.path || + !await downloads.validate(id)) { + throw StateError('Offline package is incomplete or damaged.'); + } + for (final name in { + ...package.manifests.keys, + ...package.resources.map((resource) => resource.filename), + }) { + final file = File('${directory.path}/$name'); + final canonical = File(await file.resolveSymbolicLinks()); + if (canonical.parent.path != directory.path) { + throw StateError('Offline resource leaves the selected package.'); + } + // The selected root is already registered under media.. + if (name != 'media.${selected.path.split('.').last.toLowerCase()}') { + extraFiles[name] = file; + } + } + } else if (!RegExp( + r'\.(mp4|m4v|mkv|webm|mov|avi|mp3|aac)$', + caseSensitive: false, + ).hasMatch(selected.path)) { + throw UnsupportedError( + 'This local media format requires an indexed offline package.', + ); + } + result['contentType'] = _mime(selected.path); + } else { + final url = result['url']; + final uri = url is String ? Uri.tryParse(url) : null; + if (uri == null || + !{'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + !ContentPolicy.allowsUrl(url as String)) { + throw ArgumentError('A permitted HTTP(S) media URL is required.'); + } + } + Directory? disposable; + LocalMediaServer? server; + try { + File? subtitles; + if (subtitleText != null) { + if (utf8.encode(subtitleText).length > 8 * 1024 * 1024) { + throw const FormatException('Subtitle exceeds 8 MB.'); + } + final vtt = SubtitleWebVtt.convert(subtitleText); + disposable = await Directory.systemTemp.createTemp('reelbox-cast-'); + subtitles = await File('${disposable.path}/selected.vtt') + .writeAsString(vtt, encoding: utf8, flush: true); + } + if (selected == null && subtitles == null) { + return CastMediaLease(payload: result); + } + final sharing = await LocalMediaServer.start( + file: selected ?? subtitles!, + subtitle: selected == null ? null : subtitles, + bindAddress: bindAddress, + advertisedHost: advertisedHost, + extraFiles: extraFiles, + ); + server = sharing; + if (selected != null) result['url'] = sharing.mediaUri.toString(); + if (subtitles != null) { + result['subtitleUrl'] = + (selected == null ? sharing.mediaUri : sharing.subtitleUri!) + .toString(); + } + final owned = disposable; + return CastMediaLease( + payload: result, + onClose: () async { + try { + await sharing.close(); + } finally { + if (owned != null && await owned.exists()) { + await owned.delete(recursive: true); + } + } + }, + ); + } catch (_) { + try { + await server?.close(); + } finally { + if (disposable != null && await disposable.exists()) { + await disposable.delete(recursive: true); + } + } + rethrow; + } + } + + static String _mime(String path) => + switch (path.split('.').last.toLowerCase()) { + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + 'mov' => 'video/quicktime', + 'avi' => 'video/x-msvideo', + 'mp3' => 'audio/mpeg', + 'aac' => 'audio/aac', + 'mpd' => 'application/dash+xml', + 'm3u8' => 'application/vnd.apple.mpegurl', + _ => 'video/mp4', + }; +} diff --git a/lib/media/download_button.dart b/lib/media/download_button.dart new file mode 100644 index 0000000..efafdb9 --- /dev/null +++ b/lib/media/download_button.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import '../data/models.dart'; +import '../state/app_state.dart'; +import 'download_service.dart'; +import 'downloads_page.dart'; + +typedef EnqueueDownload = Future Function({ + required TitleRecord title, + required MediaSource source, + EpisodeRecord? episode, +}); + +/// The primary action always queues inside Reelbox, never an external app. +class DownloadButton extends StatefulWidget { + const DownloadButton({ + super.key, + required this.title, + required this.source, + this.episode, + this.enqueue, + this.openDownloads, + }); + + final TitleRecord title; + final MediaSource source; + final EpisodeRecord? episode; + final EnqueueDownload? enqueue; + final void Function(BuildContext context)? openDownloads; + + @override + State createState() => _DownloadButtonState(); +} + +class _DownloadButtonState extends State { + bool _starting = false; + + Future _download() async { + if (_starting) return; + setState(() => _starting = true); + try { + final host = Uri.tryParse(widget.source.url)?.host.toLowerCase() ?? ''; + if (RegExp( + 'youtube|embed', + caseSensitive: false, + ).hasMatch(widget.source.type) || + host == 'youtu.be' || + host == 'youtube.com' || + host.endsWith('.youtube.com')) { + throw UnsupportedError( + tr( + context, + 'Embedded sources cannot be downloaded. Choose a direct media source.', + 'منابع جاسازی‌شده قابل دانلود نیستند. یک منبع رسانه مستقیم انتخاب کنید.', + ), + ); + } + await (widget.enqueue ?? DownloadService.instance.enqueue)( + title: widget.title, + source: widget.source, + episode: widget.episode, + ); + if (!mounted) return; + if (widget.openDownloads case final open?) { + open(context); + } else { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const DownloadsPage())); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${tr(context, 'Could not start download', 'شروع دانلود ممکن نشد')}: ${downloadErrorMessage(error)}', + ), + ), + ); + } + } finally { + if (mounted) setState(() => _starting = false); + } + } + + @override + Widget build(BuildContext context) => FilledButton.icon( + onPressed: widget.source.url.isEmpty || _starting ? null : _download, + icon: _starting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: Text(tr(context, 'Download in Reelbox', 'دانلود در ریلباکس')), + ); +} diff --git a/lib/media/download_service.dart b/lib/media/download_service.dart new file mode 100644 index 0000000..65499b5 --- /dev/null +++ b/lib/media/download_service.dart @@ -0,0 +1,890 @@ +import 'dart:convert'; +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../data/models.dart'; +import '../data/content_policy.dart'; +import 'media_platform.dart'; +import '../state/app_state.dart'; +import 'media_helpers.dart'; +import 'media_files_stub.dart' + if (dart.library.io) 'media_files_io.dart' + as files; + +/// A snapshot of the persistent native task, not an optimistic success flag. +class DownloadEntry { + final Task task; + final String id, + title, + quality, + filename, + path, + url, + status, + error, + episodeTitle, + posterImage; + final int titleId; + final String titleKind; + final bool? downloadNotifications; + final int? episodeId; + final double progress, speed; + final int receivedBytes, totalBytes; + final TaskStatus taskStatus; + + DownloadEntry._({ + required this.task, + required this.id, + required this.title, + required this.quality, + required this.filename, + required this.path, + required this.url, + required this.status, + required this.error, + required this.episodeTitle, + required this.posterImage, + required this.titleId, + required this.titleKind, + required this.downloadNotifications, + required this.episodeId, + required this.progress, + required this.speed, + required this.receivedBytes, + required this.totalBytes, + required this.taskStatus, + }); + + factory DownloadEntry.fromTask( + Task task, { + TaskStatus taskStatus = TaskStatus.enqueued, + double progress = 0, + int totalBytes = 0, + int? receivedBytes, + double speed = 0, + String path = '', + String error = '', + }) { + Map meta = {}; + try { + final decoded = jsonDecode(task.metaData); + if (decoded is Map) meta = Map.from(decoded); + } catch (_) { + /* Tasks from older installs can lack metadata. */ + } + final fraction = taskStatus == TaskStatus.complete + ? 1.0 + : progress.isFinite + ? progress.clamp(0.0, 1.0) + : 0.0; + final size = totalBytes > 0 ? totalBytes : 0; + return DownloadEntry._( + task: task, + id: task.taskId, + title: meta['title'] as String? ?? task.displayName, + quality: meta['quality'] as String? ?? '', + filename: task.filename, + path: path, + url: task.url, + status: taskStatus.name, + error: error, + episodeTitle: meta['episodeTitle'] as String? ?? '', + posterImage: meta['posterImage'] as String? ?? '', + titleId: meta['titleId'] as int? ?? 0, + titleKind: (meta['titleKind'] as String?)?.isNotEmpty == true + ? meta['titleKind'] as String + : meta['episodeId'] == null + ? 'movie' + : 'serie', + downloadNotifications: + meta['downloadNotifications'] as bool? ?? + (task.notificationConfig != null ? true : null), + episodeId: meta['episodeId'] as int?, + progress: fraction, + speed: speed.isFinite && speed > 0 ? speed : 0, + receivedBytes: receivedBytes ?? (fraction * size).round(), + totalBytes: size, + taskStatus: taskStatus, + ); + } + + TitleRecord get restoredTitle => TitleRecord( + id: titleId, + title: title, + image: posterImage, + type: titleKind, + ); + + String get sourceType { + try { + return (jsonDecode(task.metaData) as Map)['sourceType'] as String? ?? ''; + } catch (_) { + return ''; + } + } + + bool get isSegmented => task.group == DownloadService.packageGroup; + bool get visible => + ContentPolicy.allowsTitleJson(restoredTitle.toJson()) && + ContentPolicy.allowsText(episodeTitle) && + ContentPolicy.allowsUrl(url); + bool get isComplete => taskStatus == TaskStatus.complete; + bool get canPause => taskStatus == TaskStatus.running; + bool get canResume => taskStatus == TaskStatus.paused; + bool get isActive => { + TaskStatus.running, + TaskStatus.enqueued, + TaskStatus.waitingToRetry, + }.contains(taskStatus); +} + +/// Resolve a fresh durable snapshot, awaiting both storage and file paths. +Future> readDownloadEntries( + Future> Function() load, +) async { + final records = await load(); + return Future.wait( + records.map( + (record) async => DownloadEntry.fromTask( + record.task, + taskStatus: record.status, + progress: record.progress, + totalBytes: record.expectedFileSize, + path: await record.task.filePath(), + error: record.exception?.description ?? '', + ), + ), + ); +} + +TaskNotificationConfig? downloadNotificationConfig(bool enabled) => enabled + ? TaskNotificationConfig( + running: const TaskNotification( + 'Reelbox • downloading', + '{displayName}\n{progress} • {networkSpeed}', + ), + complete: const TaskNotification( + 'Reelbox • downloaded', + '{displayName}', + ), + error: const TaskNotification( + 'Reelbox • download failed', + '{displayName}', + ), + paused: const TaskNotification('Reelbox • paused', '{displayName}'), + progressBar: true, + tapOpensFile: true, + ) + : null; + +// Shared by enqueue and restart recovery tests; metadata is the durable policy. +String downloadMetadata({ + required TitleRecord title, + required MediaSource source, + required bool notifications, + EpisodeRecord? episode, +}) => jsonEncode({ + 'title': title.title, + 'titleId': title.id, + 'titleKind': title.type, + 'downloadNotifications': notifications, + 'quality': source.quality, + 'sourceType': source.type, + 'episodeId': episode?.id, + 'episodeTitle': episode?.title ?? '', + 'posterImage': title.image, +}); + +Future resumeDownloadTask( + DownloadEntry entry, { + required void Function(Task task, TaskNotificationConfig config) configure, + required Future Function(DownloadTask task) resume, +}) async { + if (entry.task is! DownloadTask) return false; + // Task.notificationConfig is deliberately omitted by plugin serialization. + // Unknown legacy policy stays silent rather than adopting today's setting. + final config = downloadNotificationConfig( + entry.downloadNotifications ?? false, + ); + // The pinned plugin rejects an all-null notification configuration. Off-policy + // tasks have no templates registered (and this service has no group/default). + if (config != null) configure(entry.task, config); + return resume(entry.task as DownloadTask); +} + +DownloadTask createDirectDownloadTask({ + required String id, + required TitleRecord title, + required MediaSource source, + required AppSettings preferences, + EpisodeRecord? episode, + bool supportsParallel = false, +}) { + final task = DownloadTask( + taskId: id, + url: source.url, + filename: downloadFilename( + title: episode == null ? title.title : '${title.title} ${episode.title}', + identity: id, + url: source.url, + ), + baseDirectory: BaseDirectory.applicationSupport, + directory: 'offline', + group: DownloadService.group, + notificationConfig: downloadNotificationConfig( + preferences.downloadNotifications, + ), + updates: Updates.statusAndProgress, + requiresWiFi: preferences.wifiOnlyDownloads, + allowPause: true, + retries: 2, + displayName: episode == null + ? title.title + : '${title.title} • ${episode.title}', + metaData: downloadMetadata( + title: title, + source: source, + episode: episode, + notifications: preferences.downloadNotifications, + ), + ); + + // A normal GET is the compatibility path. Multipart workers require HEAD, + // a known length and advertised byte ranges; settings alone prove none of it. + if (preferences.parallelDownloads <= 1 || !supportsParallel) return task; + // 9.6.5 exposes real HTTP range chunk tasks, not concurrent whole-file copies. + return ParallelDownloadTask( + taskId: task.taskId, + url: task.url, + filename: task.filename, + chunks: preferences.parallelDownloads.clamp(1, 8), + baseDirectory: task.baseDirectory, + directory: task.directory, + group: task.group, + notificationConfig: task.notificationConfig, + updates: task.updates, + requiresWiFi: task.requiresWiFi, + allowPause: true, + retries: task.retries, + displayName: task.displayName, + metaData: task.metaData, + ); +} + +/// Rebuild a failed task without requiring a broken multipart server feature. +/// Identity, original network/notification policy and source headers survive. +DownloadTask createRetryDownloadTask( + DownloadEntry entry, { + required String id, + bool supportsParallel = false, +}) { + if (entry.task is! DownloadTask) { + throw UnsupportedError('This task is not downloadable.'); + } + final original = entry.task is ParallelDownloadTask && !supportsParallel + ? DownloadTask.fromJson({ + ...entry.task.toJson(), + 'taskType': 'DownloadTask', + }) + : entry.task as DownloadTask; + return original.copyWith( + taskId: id, + retriesRemaining: original.retries, + filename: downloadFilename( + title: entry.title, + identity: id, + url: entry.url, + ), + notificationConfig: downloadNotificationConfig( + entry.downloadNotifications ?? false, + ), + ); +} + +Future retireExcludedDownload( + TaskRecord record, { + required Future Function(String) cancel, +}) async { + final entry = DownloadEntry.fromTask(record.task, taskStatus: record.status); + if (!entry.visible && (entry.isActive || entry.canResume)) { + // Cancel suppresses stale OS notifications; no files or records are deleted. + if (!await cancel(entry.id)) { + throw StateError('Could not stop an excluded legacy download.'); + } + } +} + +Future replaceFailedDownload( + Task old, + DownloadTask replacement, { + required Future Function(DownloadTask) enqueue, + required Future Function(String) deleteRecord, +}) async { + if (!await enqueue(replacement)) { + throw StateError( + 'Android rejected the replacement download; the old record was retained.', + ); + } + await deleteRecord(old.taskId); +} + +/// Native WorkManager/UIDT jobs with the plugin's durable task database. +/// No database auto-cleanup: completed downloads remain in the offline library. +class DownloadService extends ChangeNotifier { + DownloadService._(); + static final DownloadService instance = DownloadService._(); + static const group = 'reelbox.video'; + static const packageGroup = 'reelbox.offline.packages'; + final Map _entries = {}; + final Set _removing = {}; + Future? _initialization; + StreamSubscription? _databaseSubscription; + AppState? _state; + bool _ready = false; + bool get supported => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + String? initializationError; + List get items { + // Hide excluded legacy records without deleting the user's actual files. + final result = _entries.values.where((e) => e.visible).toList() + ..sort((a, b) => b.task.creationTime.compareTo(a.task.creationTime)); + return List.unmodifiable(result); + } + + FileDownloader get _downloader => FileDownloader(); + + /// Optional live settings binding; otherwise read durable AppState settings + /// before each enqueue so the public enqueue signature needs no context. + void bindSettings(AppState state) { + if (identical(_state, state)) return; + _state?.removeListener(_settingsChanged); + _state = state; + state.addListener(_settingsChanged); + _settingsChanged(); + } + + void _settingsChanged() { + if (_state != null) { + configureNotifications(_state!.prefs.downloadNotifications); + } + if (_ready) { + unawaited( + _applyNetworkPolicy().catchError((Object e) { + initializationError = 'Could not apply download network settings.'; + notifyListeners(); + }), + ); + } + } + + Future _preferences() async { + if (_state != null) return _state!.prefs; + final storage = await SharedPreferences.getInstance(); + final raw = storage.getString(AppState.storageKey); + if (raw == null) return AppSettings(); + final library = jsonDecode(raw) as Map; + return AppSettings.fromJson( + Map.from(library['settings'] as Map), + ); + } + + Future _applyNetworkPolicy() async { + final prefs = await _preferences(); + final ok = await _downloader.requireWiFi( + prefs.wifiOnlyDownloads + ? RequireWiFi.forAllTasks + : RequireWiFi.forNoTasks, + ); + if (!ok) { + throw StateError( + 'The OS could not update the download network constraint.', + ); + } + } + + Future initialize() { + if (!supported) { + initializationError = + 'Background video downloads are available on Android only.'; + return Future.value(); + } + return _initialization ??= _initialize().catchError(( + Object error, + StackTrace stack, + ) { + _initialization = null; + initializationError = + 'Native download initialization failed. Retry on Android.'; + notifyListeners(); + Error.throwWithStackTrace(error, stack); + }); + } + + Future _initialize() async { + // Group callbacks avoid stealing the plugin's single-subscription stream. + _downloader.registerCallbacks( + group: group, + taskStatusCallback: _status, + taskProgressCallback: _progress, + ); + _databaseSubscription ??= _downloader.database.updates.listen((record) { + if (record.task.group == group) unawaited(_acceptRecord(record)); + }); + for (final record in await _downloader.database.allRecords(group: group)) { + await retireExcludedDownload( + record, + cancel: _downloader.cancelTaskWithId, + ); + } + await _downloader.start(autoCleanDatabase: false); + await _applyNetworkPolicy(); + for (final record in await _downloader.database.allRecords(group: group)) { + await _acceptRecord(record); + } + for (final package in await files.recoverPackages()) { + _acceptPackage(package); + } + configureNotifications((await _preferences()).downloadNotifications); + _ready = true; + initializationError = null; + notifyListeners(); + } + + bool _notificationsEnabled = true; + TaskNotificationConfig? get notificationConfigForNewTask => + downloadNotificationConfig(_notificationsEnabled); + + /// Task-scoped configuration prevents stale group templates from leaking into + /// new downloads after the preference is disabled. Active OS jobs keep their + /// original notification policy until they finish. + void configureNotifications(bool enabled) { + _notificationsEnabled = enabled; + } + + Future refresh() async { + if (!supported) { + throw UnsupportedError('Download refresh requires Android.'); + } + await initialize(); + final before = Map.of(_entries); + final snapshot = await readDownloadEntries( + () => _downloader.database.allRecords(group: group), + ); + final ids = snapshot.map((entry) => entry.id).toSet(); + for (final entry in snapshot) { + // A native callback that arrived during I/O is newer than this snapshot. + if (_removing.contains(entry.id) || + !identical(_entries[entry.id], before[entry.id])) { + continue; + } + _entries[entry.id] = entry; + } + _entries.removeWhere( + (id, entry) => + !entry.isSegmented && + !ids.contains(id) && + identical(entry, before[id]) && + !entry.isActive, + ); + notifyListeners(); + } + + Future _acceptRecord(TaskRecord record) async { + if (_removing.contains(record.taskId)) return; + await retireExcludedDownload(record, cancel: _downloader.cancelTaskWithId); + final previous = _entries[record.taskId]; + final path = await record.task.filePath(); + if (_removing.contains(record.taskId)) return; + // A progress callback may have overtaken asynchronous path resolution. + final latest = _entries[record.taskId]; + if (latest != null && !identical(latest, previous)) return; + _entries[record.taskId] = DownloadEntry.fromTask( + record.task, + taskStatus: record.status, + progress: record.progress, + totalBytes: record.expectedFileSize, + path: path, + speed: previous?.speed ?? 0, + error: record.exception?.description ?? '', + ); + notifyListeners(); + } + + void _status(TaskStatusUpdate update) { + if (_removing.contains(update.task.taskId)) return; + final old = _entries[update.task.taskId]; + _entries[update.task.taskId] = DownloadEntry.fromTask( + update.task, + taskStatus: update.status, + progress: old?.progress ?? 0, + totalBytes: old?.totalBytes ?? 0, + path: old?.path ?? '', + error: update.exception?.description ?? '', + ); + notifyListeners(); + } + + void _progress(TaskProgressUpdate update) { + if (_removing.contains(update.task.taskId)) return; + final old = _entries[update.task.taskId]; + _entries[update.task.taskId] = DownloadEntry.fromTask( + update.task, + taskStatus: old?.taskStatus ?? TaskStatus.running, + progress: update.progress >= 0 ? update.progress : old?.progress ?? 0, + totalBytes: update.expectedFileSize > 0 + ? update.expectedFileSize + : old?.totalBytes ?? 0, + path: old?.path ?? '', + speed: update.hasNetworkSpeed ? update.networkSpeed * 1000000 : 0, + error: old?.error ?? '', + ); + notifyListeners(); + } + + Future enqueue({ + required TitleRecord title, + required MediaSource source, + EpisodeRecord? episode, + }) async { + if (!supported) { + throw UnsupportedError('Background downloads require the Android app.'); + } + if (title.id <= 0 || + title.title.trim().isEmpty || + !ContentPolicy.allowsTitleJson(title.toJson()) || + !ContentPolicy.allowsText(episode?.title ?? '') || + !ContentPolicy.allowsUrl(source.url)) { + throw UnsupportedError( + 'This title or destination is excluded from Reelbox.', + ); + } + if (!isRemoteMedia(source.url) || + RegExp( + r'drm|widevine|playready|fairplay|protected', + caseSensitive: false, + ).hasMatch(source.type)) { + throw UnsupportedError( + 'Only authorized, non-DRM HTTP(S) media can be downloaded.', + ); + } + await initialize(); + await _applyNetworkPolicy(); + if (_entries.values.any( + (entry) => + entry.url == source.url && + (entry.isActive || entry.canResume || entry.isComplete), + )) { + throw StateError('This source is already in Downloads.'); + } + final prefs = await _preferences(); + configureNotifications(prefs.downloadNotifications); + if (prefs.downloadNotifications && + await _downloader.permissions.status(PermissionType.notifications) != + PermissionStatus.granted) { + await _downloader.permissions.request(PermissionType.notifications); + } + final id = + '${title.id}_${episode?.id ?? 0}_${DateTime.now().microsecondsSinceEpoch}'; + final task = createDirectDownloadTask( + id: id, + title: title, + source: source, + preferences: prefs, + episode: episode, + supportsParallel: + !_segmentedSource(source) && + prefs.parallelDownloads > 1 && + await files.supportsParallelDownload(source.url), + ); + if (_segmentedSource(source)) { + if (prefs.wifiOnlyDownloads && !_downloader.isWiFi) { + throw StateError( + 'Connect to Wi-Fi before preparing an offline package.', + ); + } + final snapshot = await files.preparePackage( + id: id, + url: source.url, + type: source.type, + parallel: prefs.parallelDownloads, + metadata: { + 'task': task.copyWith(group: packageGroup).toJson(), + 'notifications': prefs.downloadNotifications, + }, + ); + _acceptPackage(snapshot); + _startPackage(id); + return; + } + final enqueued = await _downloader.enqueue(task); + if (!enqueued) { + throw StateError('Android rejected the background download job.'); + } + // Read back the exact persistent task instead of calling queued success complete. + final record = await _downloader.database.recordForId(task.taskId); + if (record != null) { + await _acceptRecord(record); + } else { + _entries.putIfAbsent(id, () => DownloadEntry.fromTask(task)); + notifyListeners(); + } + } + + static bool _segmentedSource(MediaSource source) => + isSegmentedMediaSource(source); + final _packageRuns = {}; + void _acceptPackage(Map snapshot) { + final metadata = Map.from(snapshot['metadata'] as Map); + final original = Task.createFromJson( + Map.from(metadata['task'] as Map), + ); + final task = original.copyWith(filename: snapshot['manifest'] as String); + if (_removing.contains(task.taskId)) return; + final status = TaskStatus.values.firstWhere( + (s) => s.name == snapshot['status'], + orElse: () => TaskStatus.failed, + ); + final bytes = (snapshot['receivedBytes'] as num?)?.toInt() ?? 0; + _entries[task.taskId] = DownloadEntry.fromTask( + task, + taskStatus: status, + progress: (snapshot['progress'] as num?)?.toDouble() ?? 0, + receivedBytes: bytes, + totalBytes: status == TaskStatus.complete ? bytes : 0, + path: snapshot['path'] as String, + error: snapshot['error'] as String? ?? '', + ); + notifyListeners(); + } + + void _startPackage(String id) { + if (!_packageRuns.add(id)) { + throw StateError('This package is already active.'); + } + unawaited( + files + .runPackage(id, _acceptPackage) + .catchError((Object e) { + final previous = _entries[id]; + if (previous != null && !_removing.contains(id)) { + _entries[id] = DownloadEntry.fromTask( + previous.task, + taskStatus: TaskStatus.failed, + path: previous.path, + progress: previous.progress, + receivedBytes: previous.receivedBytes, + error: 'Offline package could not start: $e', + ); + notifyListeners(); + } + }) + .whenComplete(() => _packageRuns.remove(id)), + ); + } + + /// Retries preserve the original URL/type, identity and notification policy. + /// The old failed RECORD is removed only after successful new native enqueue; + /// legacy user files are not removed as a side effect of replacing a record. + Future retry(DownloadEntry entry) async { + await initialize(); + if (!entry.visible || entry.isActive || entry.canResume) { + throw StateError('This download cannot be retried now.'); + } + if (entry.isSegmented) { + _startPackage(entry.id); + return; + } + if (entry.task is! DownloadTask) { + throw UnsupportedError('This task is not downloadable.'); + } + final source = MediaSource( + url: entry.url, + quality: entry.quality, + type: entry.sourceType, + ); + if (_segmentedSource(source)) { + await enqueue( + title: entry.restoredTitle, + source: source, + episode: entry.episodeId == null + ? null + : EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle), + ); + await _downloader.database.deleteRecordWithId(entry.id); + } else { + final id = + '${entry.titleId}_${entry.episodeId ?? 0}_${DateTime.now().microsecondsSinceEpoch}'; + // A failed multipart attempt must not loop on the same server feature, + // even if HEAD incorrectly advertised ranges. Retry as one normal GET. + final next = createRetryDownloadTask(entry, id: id); + await replaceFailedDownload( + entry.task, + next, + enqueue: _downloader.enqueue, + deleteRecord: (id) async { + await _downloader.database.deleteRecordWithId(id); + if (await _downloader.database.recordForId(id) != null) { + throw StateError('Could not replace the old download record.'); + } + }, + ); + final record = await _downloader.database.recordForId(id); + if (record != null) { + await _acceptRecord(record); + } else { + _entries.putIfAbsent(id, () => DownloadEntry.fromTask(next)); + } + } + _entries.remove(entry.id); + notifyListeners(); + } + + Future reveal(DownloadEntry entry) async { + final local = await offlinePath(entry); + final path = entry.isSegmented + ? await files.exportPackage(entry.id) + : local; + if (await MediaPlatform.invoke('revealFile', {'path': path}) != + true) { + throw StateError( + 'No application could reveal or export this offline media.', + ); + } + } + + Future externalDownloader( + DownloadEntry entry, { + String packageName = 'ask', + }) async { + if (!entry.visible || !isRemoteMedia(entry.url)) { + throw UnsupportedError('This media cannot be handed off.'); + } + if (_segmentedSource(MediaSource(url: entry.url, type: entry.sourceType))) { + throw UnsupportedError( + 'Export a complete offline package instead; external downloaders may save only the manifest.', + ); + } + if (await MediaPlatform.invoke('openDownloader', { + 'url': entry.url, + 'filename': entry.filename, + 'packageName': packageName, + }) != + true) { + throw StateError('No external downloader could accept this file.'); + } + } + + Future pause(DownloadEntry entry) async { + await initialize(); + if (entry.isSegmented) { + await files.pausePackage(entry.id); + _acceptPackage(await files.loadPackage(entry.id)); + return; + } + if (entry.task is! DownloadTask || + !await _downloader.pause(entry.task as DownloadTask)) { + throw StateError( + 'Pause is unavailable. This server may not support byte ranges.', + ); + } + } + + Future resume(DownloadEntry entry) async { + await initialize(); + if (!entry.visible) { + throw UnsupportedError('This legacy title is excluded from Reelbox.'); + } + if (entry.isSegmented) { + _startPackage(entry.id); + return; + } + if (!await resumeDownloadTask( + entry, + configure: (task, config) => _downloader.configureNotificationForTask( + task, + running: config.running, + complete: config.complete, + error: config.error, + paused: config.paused, + progressBar: config.progressBar, + tapOpensFile: config.tapOpensFile, + ), + resume: _downloader.resume, + )) { + throw StateError( + 'Cannot resume this task; remove it and start a new download.', + ); + } + } + + Future cancel(DownloadEntry entry) async { + await initialize(); + if (entry.isSegmented) { + await files.pausePackage(entry.id, cancel: true); + _acceptPackage(await files.loadPackage(entry.id)); + return; + } + if (!await _downloader.cancelTaskWithId(entry.id)) { + throw StateError('The OS could not cancel this download.'); + } + } + + Future remove(DownloadEntry entry) async { + await initialize(); + if (entry.isActive || entry.canResume) await cancel(entry); + _removing.add(entry.id); + try { + if (entry.isSegmented) { + await files.deletePackage(entry.id); + _entries.remove(entry.id); + notifyListeners(); + return; + } + final path = await entry.task.filePath(); + await files.delete(path); + await _downloader.database.deleteRecordWithId(entry.id); + if (await _downloader.database.recordForId(entry.id) != null) { + throw StateError('Could not remove download record.'); + } + _entries.remove(entry.id); + notifyListeners(); + } finally { + _removing.remove(entry.id); + } + } + + Future offlinePath(DownloadEntry entry) async { + if (!entry.visible) { + throw UnsupportedError('This legacy title is excluded from Reelbox.'); + } + if (!entry.isComplete) throw StateError('The download is not complete.'); + if (entry.isSegmented) { + if (!await files.validatePackage(entry.id)) { + throw StateError( + 'Offline package files are missing or damaged. Retry to repair.', + ); + } + return (await files.loadPackage(entry.id))['path'] as String; + } + final path = await entry.task.filePath(); + if (!await files.exists(path) || + entry.totalBytes > 0 && await files.size(path) != entry.totalBytes) { + throw StateError( + 'The downloaded file is missing. Remove the entry and download again.', + ); + } + return path; + } + + Future open(DownloadEntry entry) async { + final path = await offlinePath(entry); + if (!await _downloader.openFile(filePath: path, mimeType: 'video/*')) { + throw StateError( + 'No installed application could open this video. Use Play offline in Reelbox.', + ); + } + } +} diff --git a/lib/media/downloads_page.dart b/lib/media/downloads_page.dart new file mode 100644 index 0000000..6d7a411 --- /dev/null +++ b/lib/media/downloads_page.dart @@ -0,0 +1,498 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../data/models.dart'; +import '../state/app_state.dart'; +import 'download_service.dart'; +import 'player_page.dart'; + +/// Preserve the actual failure instead of suggesting an unrelated source fix. +String downloadErrorMessage(Object error) => switch (error) { + PlatformException(:final message, :final code) => + message?.trim().isNotEmpty == true ? message! : code, + StateError(:final message) => message.toString(), + UnsupportedError(:final message) => message ?? error.toString(), + _ => error.toString(), +}; + +/// Renders the service snapshot only; no estimated progress, speed or ETA. +class DownloadTransferDetails extends StatelessWidget { + const DownloadTransferDetails({super.key, required this.entry}); + final DownloadEntry entry; + + @override + Widget build(BuildContext context) { + final status = switch (entry.taskStatus) { + TaskStatus.enqueued => + entry.task.requiresWiFi + ? tr(context, 'Queued · Wi-Fi required', 'در صف · نیازمند وای‌فای') + : tr(context, 'Queued', 'در صف'), + TaskStatus.running => tr(context, 'Downloading', 'در حال دانلود'), + TaskStatus.waitingToRetry => tr( + context, + 'Waiting to retry', + 'در انتظار تلاش دوباره', + ), + TaskStatus.paused => tr(context, 'Paused', 'متوقف‌شده'), + TaskStatus.complete => tr(context, 'Downloaded', 'دانلودشده'), + TaskStatus.failed => tr(context, 'Failed', 'ناموفق'), + TaskStatus.canceled => tr(context, 'Canceled', 'لغوشده'), + TaskStatus.notFound => tr(context, 'Source not found', 'منبع پیدا نشد'), + }; + final measured = + entry.totalBytes > 0 || entry.progress > 0 || entry.isComplete; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(status), + const SizedBox(height: 10), + LinearProgressIndicator( + value: measured + ? entry.progress + : entry.isActive + ? null + : 0, + ), + const SizedBox(height: 8), + Text( + [ + if (measured) '${(entry.progress * 100).round()}%', + if (entry.receivedBytes > 0 || entry.totalBytes > 0) + '${_bytes(entry.receivedBytes)} / ${entry.totalBytes > 0 ? _bytes(entry.totalBytes) : '—'}', + if (entry.taskStatus == TaskStatus.running && entry.speed > 0) + '${_bytes(entry.speed.round())}/s', + ].join(' · '), + textDirection: TextDirection.ltr, + style: Theme.of(context).textTheme.bodySmall, + ), + if (entry.error.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + entry.error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + } +} + +/// Labeled primary operations; pending native acknowledgments disable repeats. +class DownloadControls extends StatefulWidget { + const DownloadControls({ + super.key, + required this.entry, + required this.onPause, + required this.onResume, + required this.onRetry, + required this.onCancel, + }); + final DownloadEntry entry; + final Future Function() onPause, onResume, onRetry, onCancel; + + @override + State createState() => _DownloadControlsState(); +} + +class _DownloadControlsState extends State { + bool _busy = false; + + Future _run(Future Function() operation) async { + if (_busy) return; + setState(() => _busy = true); + try { + await operation(); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(downloadErrorMessage(error)))); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (widget.entry.canPause) + FilledButton.tonalIcon( + onPressed: _busy ? null : () => _run(widget.onPause), + icon: const Icon(Icons.pause), + label: Text(tr(context, 'Pause', 'توقف')), + ), + if (widget.entry.canResume) + FilledButton.tonalIcon( + onPressed: _busy ? null : () => _run(widget.onResume), + icon: const Icon(Icons.play_arrow), + label: Text(tr(context, 'Resume', 'ادامه')), + ), + if (!widget.entry.isActive && + !widget.entry.canResume && + !widget.entry.isComplete) + FilledButton.tonalIcon( + onPressed: _busy ? null : () => _run(widget.onRetry), + icon: const Icon(Icons.refresh), + label: Text(tr(context, 'Retry', 'تلاش دوباره')), + ), + if (widget.entry.isActive || widget.entry.canResume) + OutlinedButton.icon( + onPressed: _busy ? null : () => _run(widget.onCancel), + icon: const Icon(Icons.cancel_outlined), + label: Text(tr(context, 'Cancel', 'لغو')), + ), + if (_busy) + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ); +} + +/// Optional handoff stays secondary to Reelbox's own queue and controls. +class ExternalDownloadMenu extends StatelessWidget { + const ExternalDownloadMenu({super.key, required this.onSelected}); + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) => PopupMenuButton( + tooltip: tr(context, 'More download options', 'گزینه‌های بیشتر دانلود'), + icon: const Icon(Icons.more_vert), + onSelected: onSelected, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'ask', + child: Text( + tr(context, 'Choose external downloader…', 'انتخاب دانلودر بیرونی…'), + ), + ), + PopupMenuItem( + value: 'ADM', + child: Text( + tr(context, 'External downloader: ADM', 'دانلودر بیرونی: ADM'), + ), + ), + PopupMenuItem( + value: 'IDM', + child: Text( + tr(context, 'External downloader: IDM', 'دانلودر بیرونی: IDM'), + ), + ), + ], + ); +} + +String _bytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; +} + +class DownloadsPage extends StatefulWidget { + const DownloadsPage({super.key}); + @override + State createState() => _DownloadsPageState(); +} + +class _DownloadsPageState extends State { + final service = DownloadService.instance; + Future? startup; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + service.bindSettings(AppScope.read(context)); + startup ??= service.initialize(); + } + + Future action(Future operation) async { + try { + await operation; + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(tr(context, 'Downloads', 'دانلودها'))), + body: !service.supported + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + tr( + context, + 'Background downloads and offline playback are available in the Android app.', + 'دانلود پس‌زمینه و پخش آفلاین در برنامه اندروید در دسترس است.', + ), + textAlign: TextAlign.center, + ), + ), + ) + : FutureBuilder( + future: startup, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Text( + service.initializationError ?? + 'Download initialization failed.', + ), + ), + FilledButton( + onPressed: () => setState(() { + startup = service.initialize(); + }), + child: Text(tr(context, 'Retry', 'تلاش دوباره')), + ), + ], + ), + ); + } + return AnimatedBuilder( + animation: service, + builder: (context, _) { + final items = service.items; + if (items.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.download_for_offline_outlined, + size: 52, + ), + const SizedBox(height: 18), + Text( + tr( + context, + 'Your offline library starts here', + 'کتابخانه آفلاین شما از اینجا شروع می‌شود', + ), + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + tr( + context, + 'Choose a quality on a movie or episode and tap Download.', + 'در صفحه فیلم یا قسمت، کیفیت را انتخاب کنید و دانلود را بزنید.', + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + return RefreshIndicator( + onRefresh: () => action(service.refresh()), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: items.length, + itemBuilder: (_, i) { + final entry = items[i]; + return Card( + margin: const EdgeInsets.only(bottom: 14), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title, + style: Theme.of(context) + .textTheme + .titleMedium, + ), + if (entry.episodeTitle.isNotEmpty) + Text(entry.episodeTitle), + const SizedBox(height: 6), + if (entry.quality.isNotEmpty) + Text(entry.quality), + if (entry.isSegmented) + Text( + tr( + context, + 'Offline package · interrupted packaging resumes from verified segments after reopening. Export includes all media files.', + 'بسته آفلاین · پس از باز کردن دوباره، از قطعه‌های تأییدشده ادامه می‌یابد. خروجی شامل همه فایل‌ها است.', + ), + style: Theme.of(context) + .textTheme + .bodySmall, + ), + const SizedBox(height: 10), + DownloadTransferDetails(entry: entry), + Wrap( + spacing: 8, + children: [ + DownloadControls( + key: ValueKey('controls:${entry.id}'), + entry: entry, + onPause: () => service.pause(entry), + onResume: () => service.resume(entry), + onRetry: () => service.retry(entry), + onCancel: () => service.cancel(entry), + ), + if (entry.isComplete) ...[ + FilledButton.tonalIcon( + icon: const Icon(Icons.play_arrow), + label: Text( + tr( + context, + 'Play offline', + 'پخش آفلاین', + ), + ), + onPressed: () => + action(playOffline(entry)), + ), + IconButton( + tooltip: tr( + context, + 'Open file externally', + 'باز کردن فایل بیرونی', + ), + icon: const Icon(Icons.open_in_new), + onPressed: () => + action(service.open(entry)), + ), + IconButton( + tooltip: tr( + context, + 'Reveal / export offline media', + 'نمایش / خروجی رسانه آفلاین', + ), + icon: const Icon(Icons.ios_share), + onPressed: () => + action(service.reveal(entry)), + ), + ], + if (!entry.isSegmented) + ExternalDownloadMenu( + onSelected: (package) => action( + service.externalDownloader( + entry, + packageName: package, + ), + ), + ), + IconButton( + tooltip: tr( + context, + 'Delete file and record', + 'حذف فایل و سابقه', + ), + icon: const Icon(Icons.delete_outline), + onPressed: () async { + final confirmed = + await showDialog( + context: context, + builder: (dialogContext) => + AlertDialog( + title: Text( + tr( + context, + 'Delete this download?', + 'این دانلود حذف شود؟', + ), + ), + content: Text(entry.title), + actions: [ + TextButton( + onPressed: () => + Navigator.pop( + dialogContext, + false, + ), + child: Text( + tr( + context, + 'Cancel', + 'انصراف', + ), + ), + ), + FilledButton( + onPressed: () => + Navigator.pop( + dialogContext, + true, + ), + child: Text( + tr( + context, + 'Delete', + 'حذف', + ), + ), + ), + ], + ), + ); + if (confirmed == true) { + await action(service.remove(entry)); + } + }, + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + }, + ); + }, + ), + ); + Future playOffline(DownloadEntry entry) async { + final path = await service.offlinePath(entry); + if (!mounted) return; + final title = entry.restoredTitle; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + title: title, + source: MediaSource( + url: entry.url, + quality: entry.quality, + type: entry.sourceType, + ), + localFile: path, + episode: entry.episodeId == null + ? null + : EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle), + ), + ), + ); + } +} diff --git a/lib/media/local_media_server.dart b/lib/media/local_media_server.dart new file mode 100644 index 0000000..6cf609b --- /dev/null +++ b/lib/media/local_media_server.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +/// Explicit, short-lived private-LAN media sharing. The random session URL is a +/// bearer capability; never log/persist it. Only caller-registered files exist +/// in this HTTP namespace. No filesystem path is derived from a request. +class LocalMediaServer { + LocalMediaServer._( + this._server, + this._host, + this._token, + this._files, + this._mediaName, + this._subtitleName, + ) : _port = _server.port { + _server.listen((request) { + final operation = _handle(request); + _pending.add(operation); + unawaited(operation.whenComplete(() => _pending.remove(operation))); + }, onError: (Object _) {}); + } + + final HttpServer _server; + final String _host, _token, _mediaName; + final String? _subtitleName; + final int _port; + final Map _files; + final Set> _pending = {}; + Future? _closing; + bool _closed = false; + + static Future start({ + required File file, + File? subtitle, + InternetAddress? bindAddress, + String? advertisedHost, + Map extraFiles = const {}, + }) async { + final address = bindAddress ?? await _discoverAddress(); + final hostAddress = InternetAddress.tryParse( + advertisedHost ?? address.address, + ); + if (hostAddress == null || + (!hostAddress.isLoopback && !_privateV4(hostAddress))) { + throw ArgumentError( + 'Choose a private LAN address for local media sharing.', + ); + } + if (!address.isLoopback && + !_privateV4(address) && + address.address != '0.0.0.0') { + throw ArgumentError( + 'Local media sharing cannot bind a public interface.', + ); + } + final mediaName = 'media${_extension(file)}'; + final subtitleName = subtitle == null + ? null + : 'subtitle${_extension(subtitle)}'; + final selected = {mediaName: file}; + if (subtitleName != null && subtitle != null) { + selected[subtitleName] = subtitle; + } + for (final entry in extraFiles.entries) { + if (!_safeName(entry.key) || selected.containsKey(entry.key)) { + throw ArgumentError('Invalid or reserved local resource name.'); + } + selected[entry.key] = entry.value; + } + final registered = {}; + for (final entry in selected.entries) { + registered[entry.key] = await _RegisteredMedia.create(entry.value); + } + final random = Random.secure(); + final token = base64Url + .encode(List.generate(32, (_) => random.nextInt(256))) + .replaceAll('=', ''); + final server = await HttpServer.bind(address, 0, shared: false); + server.autoCompress = false; + return LocalMediaServer._( + server, + hostAddress.address, + token, + registered, + mediaName, + subtitleName, + ); + } + + static bool _safeName(String name) => + name.isNotEmpty && + name + .split('/') + .every( + (part) => + part.isNotEmpty && + !part.startsWith('.') && + RegExp(r'^[a-zA-Z0-9_.-]+$').hasMatch(part), + ); + + Uri _uri(String name) => Uri( + scheme: 'http', + host: _host, + port: _port, + pathSegments: [_token, ...name.split('/')], + ); + Uri get mediaUri => _uri(_mediaName); + Uri? get subtitleUri => _subtitleName == null ? null : _uri(_subtitleName); + + Future close() => _closing ??= _close(); + Future _close() async { + _closed = true; + await _server.close(force: true); + await Future.wait(_pending.toList()); + } + + Future _handle(HttpRequest request) async { + final response = request.response; + try { + final parts = request.uri.pathSegments; + final name = parts.length > 1 ? parts.skip(1).join('/') : ''; + final resource = + parts.isNotEmpty && parts.first == _token && _safeName(name) + ? _files[name] + : null; + if (_closed || resource == null || !await resource.unchanged()) { + response.statusCode = HttpStatus.notFound; + response.contentLength = 0; + await response.close(); + return; + } + response.headers.set('access-control-allow-origin', '*'); + response.headers.set( + 'access-control-allow-methods', + 'GET, HEAD, OPTIONS', + ); + response.headers.set('access-control-allow-headers', 'Range, If-Range'); + response.headers.set( + 'access-control-expose-headers', + 'Content-Length, Content-Range, Accept-Ranges', + ); + if (request.headers.value('access-control-request-private-network') == + 'true') { + response.headers.set('access-control-allow-private-network', 'true'); + } + response.headers.set(HttpHeaders.cacheControlHeader, 'no-store'); + if (request.method == 'OPTIONS') { + response.statusCode = HttpStatus.noContent; + response.contentLength = 0; + await response.close(); + return; + } + if (request.method != 'GET' && request.method != 'HEAD') { + response.statusCode = HttpStatus.methodNotAllowed; + response.headers.set(HttpHeaders.allowHeader, 'GET, HEAD, OPTIONS'); + response.contentLength = 0; + await response.close(); + return; + } + final size = resource.stat.size; + response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + response.headers.set( + HttpHeaders.contentTypeHeader, + _contentType(resource.source), + ); + response.headers.set( + HttpHeaders.lastModifiedHeader, + HttpDate.format(resource.stat.modified.toUtc()), + ); + var start = 0, end = size - 1; + final range = request.headers.value(HttpHeaders.rangeHeader); + if (range != null) { + final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim()); + var valid = + match != null && + size > 0 && + (match.group(1)!.isNotEmpty || match.group(2)!.isNotEmpty); + if (valid) { + if (match.group(1)!.isEmpty) { + final suffix = int.tryParse(match.group(2)!); + valid = suffix != null && suffix > 0; + if (valid) start = max(0, size - suffix); + } else { + final first = int.tryParse(match.group(1)!); + final last = match.group(2)!.isEmpty + ? size - 1 + : int.tryParse(match.group(2)!); + valid = + first != null && last != null && first < size && last >= first; + if (valid) { + start = first; + end = min(last, size - 1); + } + } + } + if (!valid) { + response.statusCode = HttpStatus.requestedRangeNotSatisfiable; + response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$size'); + response.contentLength = 0; + await response.close(); + return; + } + response.statusCode = HttpStatus.partialContent; + response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes $start-$end/$size', + ); + } + response.contentLength = size == 0 ? 0 : end - start + 1; + if (request.method == 'GET' && size > 0) { + await response.addStream( + File(resource.canonicalPath).openRead(start, end + 1), + ); + } + await response.close(); + } catch (_) { + // Never expose filesystem paths or capability tokens to a disconnected peer. + try { + await response.close(); + } catch (_) { + /* Socket already closed. */ + } + } + } + + static String _extension(File file) => + RegExp(r'\.[A-Za-z0-9]{1,8}$') + .firstMatch(file.path) + ?.group(0) + ?.toLowerCase() ?? + ''; + static String _contentType(File file) => switch (_extension(file)) { + '.mp4' || '.m4v' => 'video/mp4', + '.mkv' => 'video/x-matroska', + '.webm' => 'video/webm', + '.m3u8' => 'application/vnd.apple.mpegurl', + '.mpd' => 'application/dash+xml', + '.ts' => 'video/mp2t', + '.m4s' => 'video/iso.segment', + '.aac' => 'audio/aac', + '.mp3' => 'audio/mpeg', + '.vtt' => 'text/vtt; charset=utf-8', + '.srt' => 'text/plain; charset=utf-8', + '.ttml' || '.xml' => 'application/ttml+xml', + _ => 'application/octet-stream', + }; + static bool _privateV4(InternetAddress address) { + if (address.type != InternetAddressType.IPv4) return false; + final b = address.rawAddress; + return b[0] == 10 || + (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || + (b[0] == 192 && b[1] == 168); + } + + static Future _discoverAddress() async { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + ); + for (final interface in interfaces) { + if (!RegExp( + r'^(wlan|wifi|eth|en)', + caseSensitive: false, + ).hasMatch(interface.name)) { + continue; + } + for (final address in interface.addresses) { + if (_privateV4(address)) return address; + } + } + throw StateError( + 'Connect to a private Wi-Fi or Ethernet network to share offline media.', + ); + } +} + +class _RegisteredMedia { + const _RegisteredMedia(this.source, this.canonicalPath, this.stat); + final File source; + final String canonicalPath; + final FileStat stat; + static Future<_RegisteredMedia> create(File file) async { + final source = file.absolute; + try { + final path = await source.resolveSymbolicLinks(); + final stat = await File(path).stat(); + if (stat.type != FileSystemEntityType.file) { + throw ArgumentError('Select an existing media file.'); + } + return _RegisteredMedia(source, path, stat); + } on FileSystemException { + throw ArgumentError('The selected media file is unavailable.'); + } + } + + Future unchanged() async { + try { + if (await source.resolveSymbolicLinks() != canonicalPath) return false; + final current = await File(canonicalPath).stat(); + return current.type == FileSystemEntityType.file && + current.size == stat.size && + current.modified == stat.modified; + } on FileSystemException { + return false; + } + } +} diff --git a/lib/media/media_files_io.dart b/lib/media/media_files_io.dart new file mode 100644 index 0000000..0e2b50f --- /dev/null +++ b/lib/media/media_files_io.dart @@ -0,0 +1,220 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'segmented_download.dart'; +import '../data/content_policy.dart'; + +import 'dart:convert'; + +/// The native multipart worker starts with HEAD, so only opt into it when +/// that exact capability is advertised. A failed/unsupported probe is not a +/// failed download: the caller schedules an ordinary streaming GET instead. +Future supportsParallelDownload(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || + !{'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty || + !ContentPolicy.allowsUrl(url)) { + return false; + } + final client = HttpClient()..connectionTimeout = const Duration(seconds: 3); + try { + final request = await client + .openUrl('HEAD', uri) + .timeout(const Duration(seconds: 4)); + request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity'); + final response = await request.close().timeout(const Duration(seconds: 4)); + return response.statusCode == HttpStatus.ok && + response.contentLength > 0 && + (response.headers[HttpHeaders.acceptRangesHeader] ?? const []) + .expand((value) => value.toLowerCase().split(',')) + .any((value) => value.trim() == 'bytes'); + } catch (_) { + return false; + } finally { + client.close(force: true); + } +} + +Future exists(String path) async => + await File(path).exists() && await File(path).length() > 0; +Future size(String path) => File(path).length(); +Future delete(String path) async { + final file = File(path); + if (await file.exists()) await file.delete(); +} + +const _segmentGroup = 'reelbox.offline.resources'; +Future? _packageEngine; +final _nativeTasks = >{}; +final _activePackages = {}; +Future _engine() => _packageEngine ??= () async { + final support = await getApplicationSupportDirectory(); + return SegmentedDownload( + root: Directory('${support.path}/offline_packages'), + transfer: _nativeTransfer, + cancelTransfers: _cancelTransfers, + ); +}(); + +Future _cancelTransfers(String id) async { + final ids = _nativeTasks[id]?.toList() ?? []; + if (ids.isNotEmpty && !await FileDownloader().cancelTasksWithIds(ids)) { + throw StateError('Android could not cancel an offline resource task.'); + } +} + +Future _nativeTransfer( + OfflinePackage p, + OfflineResource r, + String destination, +) async { + final original = Task.createFromJson( + Map.from(p.metadata['task'] as Map), + ); + final metadata = original.metaData; + final meta = Map.from(jsonDecode(metadata) as Map); + if (!ContentPolicy.allowsTitleJson({ + 'title': meta['title'], + 'type': meta['titleKind'], + }) || + !ContentPolicy.allowsText(meta['episodeTitle'] as String? ?? '') || + !ContentPolicy.allowsUrl(r.url)) { + throw UnsupportedError( + 'Excluded offline metadata cannot start a native job.', + ); + } + final id = '${p.id}_${r.filename}_${DateTime.now().microsecondsSinceEpoch}'; + final enabled = (p.metadata['notifications'] as bool?) ?? false; + final task = DownloadTask( + taskId: id, + url: r.url, + headers: { + 'Accept-Encoding': 'identity', + if (r.offset != null) + 'Range': 'bytes=${r.offset}-${r.offset! + r.length! - 1}', + }, + // Per-run names keep orphan OS writers away from the resumed destination. + filename: '${File(destination).uri.pathSegments.last}.$id.part', + directory: File(destination).parent.path, + baseDirectory: BaseDirectory.root, + group: _segmentGroup, + requiresWiFi: original.requiresWiFi, + retries: 2, + allowPause: false, + updates: Updates.statusAndProgress, + displayName: original.displayName, + metaData: metadata, + notificationConfig: enabled + ? TaskNotificationConfig( + running: const TaskNotification( + 'Reelbox • saving offline resource', + '{displayName}\n{progress}', + ), + error: const TaskNotification( + 'Reelbox • offline resource failed', + '{displayName}', + ), + progressBar: true, + tapOpensFile: false, + ) + : null, + ); + (_nativeTasks[p.id] ??= {}).add(id); + try { + final update = await FileDownloader().download(task); + if (update.status != TaskStatus.complete) { + throw StateError('Native resource download ${update.status.name}.'); + } + final code = update.responseStatusCode; + if (r.offset != null) { + final range = update.responseHeaders?['content-range'] ?? ''; + if (code != 206 || + !RegExp('^bytes ${r.offset}-${r.offset! + r.length! - 1}/[0-9]+\$') + .hasMatch(range)) { + throw const FormatException( + 'Server did not honor the resource byte range.', + ); + } + } else if (code != 200) { + throw StateError('Native resource HTTP $code.'); + } + final expected = int.tryParse( + update.responseHeaders?['content-length'] ?? '', + ); + final downloaded = File(await task.filePath()); + if (expected != null && expected != await downloaded.length()) { + throw const FormatException('Truncated native resource.'); + } + await downloaded.rename(destination); + } finally { + _nativeTasks[p.id]?.remove(id); + await FileDownloader().database.deleteRecordWithId(id); + } +} + +Map _snapshot(OfflinePackage p) => { + ...p.toJson(), + 'path': p.path, + 'directory': p.directory, + 'receivedBytes': p.receivedBytes, + 'progress': p.progress, +}; +Future>> recoverPackages() async { + // Interrupted child jobs must not race a newly resumed writer to the same .part. + final downloader = FileDownloader(); + final tasks = await downloader.allTasks(group: _segmentGroup); + final orphans = tasks + .where((t) => !_nativeTasks.values.any((ids) => ids.contains(t.taskId))) + .toList(); + if (orphans.isNotEmpty && + !await downloader.cancelTasksWithIds( + orphans.map((t) => t.taskId).toList(), + )) { + throw StateError( + 'Cannot recover while Android still owns an old resource job.', + ); + } + return (await (await _engine()).recover()).map(_snapshot).toList(); +} + +Future> preparePackage({ + required String id, + required String url, + required String type, + required Map metadata, + required int parallel, +}) async { + final engine = await _engine(); + engine.parallel = parallel.clamp(1, 8); + return _snapshot( + await engine.prepare(id: id, url: url, type: type, metadata: metadata), + ); +} + +Future runPackage( + String id, + void Function(Map) onProgress, +) async { + if (!_activePackages.add(id)) { + throw StateError('Offline package is already active.'); + } + try { + await (await _engine()).run( + id, + onProgress: (p) => onProgress(_snapshot(p)), + ); + } finally { + _activePackages.remove(id); + } +} + +Future pausePackage(String id, {bool cancel = false}) async => + (await _engine()).stop(id, cancel: cancel); +Future deletePackage(String id) async => (await _engine()).delete(id); +Future exportPackage(String id) async => (await _engine()).export(id); +Future validatePackage(String id) async => (await _engine()).validate(id); +Future> loadPackage(String id) async => + _snapshot(await (await _engine()).load(id)); diff --git a/lib/media/media_files_stub.dart b/lib/media/media_files_stub.dart new file mode 100644 index 0000000..3e28ea8 --- /dev/null +++ b/lib/media/media_files_stub.dart @@ -0,0 +1,26 @@ +Future supportsParallelDownload(String url) async => false; +Future exists(String path) async => false; +Future size(String path) async => 0; +Future delete(String path) async => + throw UnsupportedError('Native files are unavailable in the browser.'); +Future>> recoverPackages() async => []; +Future> preparePackage({ + required String id, + required String url, + required String type, + required Map metadata, + required int parallel, +}) async => throw UnsupportedError('Offline packages require the Android app.'); +Future runPackage( + String id, + void Function(Map) onProgress, +) async => throw UnsupportedError('Offline packages require the Android app.'); +Future pausePackage(String id, {bool cancel = false}) async => + throw UnsupportedError('Offline packages require the Android app.'); +Future deletePackage(String id) async => + throw UnsupportedError('Offline packages require the Android app.'); +Future exportPackage(String id) async => + throw UnsupportedError('Offline packages require the Android app.'); +Future validatePackage(String id) async => false; +Future> loadPackage(String id) async => + throw UnsupportedError('Offline packages require the Android app.'); diff --git a/lib/media/media_helpers.dart b/lib/media/media_helpers.dart new file mode 100644 index 0000000..3c422d9 --- /dev/null +++ b/lib/media/media_helpers.dart @@ -0,0 +1,443 @@ +import 'dart:convert'; + +import 'package:media_kit/media_kit.dart'; + +import '../data/models.dart'; +import '../state/app_state.dart'; + +/// Orchestrates pending player work across route disposal/source changes. +class PlaybackLifecycle { + PlaybackLifecycle({this.isMounted}); + final bool Function()? isMounted; + bool _disposed = false; + int _openGeneration = 0; + // Platform/window state is shared by every player route, not just a widget. + static Future? _systemTail; + static PlaybackLifecycle? _systemOwner; + bool get ownsSystem => _active && identical(_systemOwner, this); + bool get _active => !_disposed && (isMounted?.call() ?? true); + + static Future _serialize(Future Function() action) { + final previous = _systemTail; + // Drop completed futures when idle, rather than keeping a previous player's + // scheduling zone alive. Work is still serialized across active routes. + final work = previous == null + ? Future.microtask(action) + : previous.then((_) => action()); + late final Future tail; + tail = work + .then((_) {}, onError: (Object _, StackTrace _) {}) + .whenComplete(() { + if (identical(_systemTail, tail)) _systemTail = null; + }); + _systemTail = tail; + return work; + } + + Future setup(List Function()> steps) { + // Capture ownership before even awaiting the system-work queue. + final activeAtStart = _active; + return _serialize(() async { + if (!activeAtStart || !_active) return false; + _systemOwner = this; + for (final step in steps) { + await step(); + if (!_active) return false; + } + return true; + }); + } + + Future open({ + required Future Function() save, + required Future Function(bool Function() current) dispatch, + }) async { + final generation = ++_openGeneration; + bool current() => _active && generation == _openGeneration; + if (!current()) return false; + await save(); + if (!current()) return false; + await dispatch(current); + return current(); + } + + Future dispose(Future Function() restore) { + _disposed = true; + _openGeneration++; + // A previous route must never restore global state over a newer owner. + return _serialize(() async { + if (!identical(_systemOwner, this)) return; + await restore(); + if (identical(_systemOwner, this)) _systemOwner = null; + }); + } +} + +Future dispatchMediaOpen({ + required Future Function(Media media, {bool play}) open, + required String uri, + required Duration start, + required bool autoplay, +}) => open(Media(uri, start: start), play: autoplay); + +/// Keep startup/failed-open zero state out of durable resume history. +class PlaybackCheckpoint { + Duration position = Duration.zero; + Duration duration = Duration.zero; + bool _opened = false; + bool get canPersist => _opened && position > Duration.zero; + void beginOpen() { + _opened = false; + position = Duration.zero; + duration = Duration.zero; + } + + void didOpen() { + _opened = true; + } +} + +EpisodeRecord? nextEpisode(List episodes, int? currentId) { + final index = episodes.indexWhere((episode) => episode.id == currentId); + return index >= 0 && index + 1 < episodes.length ? episodes[index + 1] : null; +} + +String watchKey(int titleId, int? episodeId) => + '$titleId:${episodeId ?? 'movie'}'; + +Duration resumePosition({ + required Duration position, + required Duration duration, + int rewindSeconds = 0, +}) { + if (position < const Duration(seconds: 5)) return Duration.zero; + if (duration > Duration.zero && + (position >= duration - const Duration(seconds: 15) || + position.inMilliseconds / duration.inMilliseconds >= .95)) { + return Duration.zero; + } + return Duration( + milliseconds: (position.inMilliseconds - rewindSeconds.clamp(0, 120) * 1000) + .clamp(0, position.inMilliseconds), + ); +} + +/// Shared by enqueue, retry and external handoff: manifest aliases must never +/// fall through to a direct-file job, including extensionless MPD endpoints. +bool isSegmentedMediaSource(MediaSource source) { + final path = Uri.tryParse(source.url)?.path.toLowerCase() ?? ''; + final type = source.type.split(';').first.trim().toLowerCase(); + return path.endsWith('.m3u8') || + path.endsWith('.mpd') || + type == 'mpd' || + RegExp(r'hls|dash|m3u8|mpegurl').hasMatch(type); +} + +String? downloadUnsupportedReason(MediaSource source) { + if (!isRemoteMedia(source.url)) { + return 'Only public HTTP(S) files can be downloaded.'; + } + if (isSegmentedMediaSource(source)) { + return 'Use offline HLS/DASH segment packaging instead of a direct video file.'; + } + return null; +} + +String downloadFilename({ + required String title, + required String identity, + required String url, +}) { + var name = title + .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), '_') + .replaceAll(RegExp(r'^\.+_?'), '') + .trim(); + if (name.isEmpty) name = 'Video'; + if (name.length > 90) name = name.substring(0, 90); + final path = Uri.tryParse(url)?.path.toLowerCase() ?? ''; + final ext = + RegExp(r'\.(mp4|mkv|webm|mov|avi|m4v|ts)$').firstMatch(path)?.group(1) ?? + 'mp4'; + return '${name}_$identity.$ext'; +} + +bool isRemoteMedia(String url) { + final uri = Uri.tryParse(url); + return uri != null && + (uri.scheme == 'https' || uri.scheme == 'http') && + uri.host.isNotEmpty; +} + +MediaSource? selectSource( + Iterable sources, { + String preferredQuality = '', +}) { + final options = playableSources(sources); + if (options.isEmpty) return null; + return options.firstWhere( + (s) => s.quality == preferredQuality, + orElse: () => options.first, + ); +} + +List playableSources(Iterable sources) { + final seen = {}; + return sources + .where((s) => !s.isTrailer && isRemoteMedia(s.url) && seen.add(s.url)) + .toList(); +} + +/// Strict Unicode first; legacy Persian/Arabic files commonly use Windows-1256. +String decodeSubtitle(List bytes) { + if (bytes.length >= 2 && + ((bytes[0] == 255 && bytes[1] == 254) || + (bytes[0] == 254 && bytes[1] == 255))) { + final little = bytes[0] == 255; + if (bytes.length.isOdd) { + throw const FormatException('Truncated UTF-16 subtitle'); + } + return String.fromCharCodes([ + for (var i = 2; i + 1 < bytes.length; i += 2) + little ? bytes[i] | bytes[i + 1] << 8 : bytes[i] << 8 | bytes[i + 1], + ]); + } + try { + return utf8.decode(bytes).replaceFirst(RegExp(r'^\uFEFF'), ''); + } on FormatException { + return String.fromCharCodes( + bytes.map((b) => b < 128 ? b : _windows1256[b - 128]), + ); + } +} + +// Unicode mapping of the Windows-1256 high-byte range (not Latin-1). +const _windows1256 = [ + 8364, + 1662, + 8218, + 402, + 8222, + 8230, + 8224, + 8225, + 710, + 8240, + 1657, + 8249, + 338, + 1670, + 1688, + 1672, + 1711, + 8216, + 8217, + 8220, + 8221, + 8226, + 8211, + 8212, + 1705, + 8482, + 1681, + 8250, + 339, + 8204, + 8205, + 1722, + 160, + 1548, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 1726, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 1563, + 187, + 188, + 189, + 190, + 1567, + 1729, + 1569, + 1570, + 1571, + 1572, + 1573, + 1574, + 1575, + 1576, + 1577, + 1578, + 1579, + 1580, + 1581, + 1582, + 1583, + 1584, + 1585, + 1586, + 1587, + 1588, + 1589, + 1590, + 215, + 1591, + 1592, + 1593, + 1594, + 1600, + 1601, + 1602, + 1603, + 224, + 1604, + 226, + 1605, + 1606, + 1607, + 1608, + 231, + 232, + 233, + 234, + 235, + 1609, + 1610, + 238, + 239, + 1611, + 1612, + 1613, + 1614, + 244, + 1615, + 1616, + 247, + 1617, + 249, + 1618, + 251, + 252, + 8206, + 8207, + 1746, +]; + +class SubtitleVariant { + const SubtitleVariant(this.language, this.label, this.url); + final String language, label, url; +} + +/// Accept flat lists as well as language groups containing release variants. +Map> subtitleGroups( + List> rows, +) { + final result = >{}; + final seen = {}; + String text(dynamic value) => value is Map + ? '${value['name'] ?? value['title'] ?? value['code'] ?? ''}' + : value is String + ? value + : ''; + void visit(Map item, String inherited) { + final lang = text(item['language']).trim(); + final language = lang.isEmpty ? inherited : lang; + final url = text(item['url'] ?? item['link']); + if (isRemoteMedia(url) && seen.add(url)) { + final label = text(item['title'] ?? item['name'] ?? item['quality']); + (result[language] ??= []).add( + SubtitleVariant(language, label.isEmpty ? 'Subtitle' : label, url), + ); + } + for (final key in ['subtitles', 'variants', 'files', 'releases', 'items']) { + final children = item[key]; + if (children is List) { + for (final child in children.whereType()) { + visit(child, language); + } + } + } + } + + for (final item in rows) { + visit(item, 'Other'); + } + return result; +} + +Future applyPlayerOptions( + AppSettings prefs, { + required Future Function(double) rate, + required Future Function(bool) audioOnly, + required Future Function(double) volume, + bool Function()? current, +}) async { + if (current != null && !current()) return; + await rate(prefs.playbackSpeed); + if (current != null && !current()) return; + await audioOnly(prefs.audioOnly); + if (current != null && !current()) return; + if (prefs.rememberVolume) await volume(prefs.playerVolume); +} + +AppSettings resetSubtitleSettings(AppSettings prefs) { + final defaults = AppSettings(); + return prefs.copyWith( + subtitleEnabled: defaults.subtitleEnabled, + subtitleFontFamily: defaults.subtitleFontFamily, + subtitleFontSize: defaults.subtitleFontSize, + subtitleColor: defaults.subtitleColor, + subtitleBackground: defaults.subtitleBackground, + subtitleDelay: defaults.subtitleDelay, + ); +} + +AppSettings resetPlayerSettings(AppSettings prefs) { + final d = AppSettings(); + return resetSubtitleSettings(prefs).copyWith( + playbackSpeed: d.playbackSpeed, + audioDelay: d.audioDelay, + videoFit: d.videoFit, + audioOnly: d.audioOnly, + playerVolume: d.playerVolume, + rememberVolume: d.rememberVolume, + backgroundAudio: d.backgroundAudio, + closeOnCompletion: d.closeOnCompletion, + resumeRewindSeconds: d.resumeRewindSeconds, + skipSeconds: d.skipSeconds, + resumePlayback: d.resumePlayback, + autoplay: d.autoplay, + autoNext: d.autoNext, + orientation: d.orientation, + externalPlayer: d.externalPlayer, + externalPlayerPackage: d.externalPlayerPackage, + ); +} + +String mediaContentType(String url) { + final path = Uri.tryParse(url)?.path.toLowerCase() ?? ''; + if (path.endsWith('.m3u8')) return 'application/x-mpegURL'; + if (path.endsWith('.mpd')) return 'application/dash+xml'; + if (path.endsWith('.webm')) return 'video/webm'; + if (path.endsWith('.mkv')) return 'video/x-matroska'; + return 'video/mp4'; +} diff --git a/lib/media/media_platform.dart b/lib/media/media_platform.dart new file mode 100644 index 0000000..852f146 --- /dev/null +++ b/lib/media/media_platform.dart @@ -0,0 +1,397 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Captures Android controls on entry and restores the activity's brightness +/// override on exit. System volume is intentionally not restored. +class PlayerSystemControls { + double brightness = .5, volume = .5; + // The activity baseline belongs to the shared player-control session. A + // replacement route must not adopt the previous route's override as baseline. + static double? _originalBrightness; + Future capture() async { + if (!MediaPlatform.supported) return; + final original = await MediaPlatform.invoke('getBrightness'); + final effective = original != null && original >= 0 + ? original + : await MediaPlatform.invoke('getEffectiveBrightness'); + final currentVolume = await MediaPlatform.invoke('getVolume'); + _originalBrightness ??= original ?? -1; + if (effective != null && effective.isFinite) { + brightness = effective.clamp(0, 1); + } + if (currentVolume != null && currentVolume.isFinite) { + volume = currentVolume.clamp(0, 1); + } + } + + Future restore() async { + final original = _originalBrightness; + _originalBrightness = null; + if (original != null && MediaPlatform.supported) { + await MediaPlatform.invoke('setBrightness', {'value': original}); + } + } +} + +/// Android channel implemented by MainActivity; no synthetic platform success. +class MediaPlatform { + static const channel = MethodChannel('org.reelbox/media'); + static bool get supported => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + static Future invoke( + String method, [ + Map? args, + ]) async { + if (!supported) { + throw UnsupportedError('This control is available on Android only.'); + } + return channel.invokeMethod(method, args); + } + + static Future enterPip() async => + await invoke('enterPip') ?? false; + static Future setActive(bool active) async { + if (supported) await invoke('setPlayerActive', {'active': active}); + } + + static Future openExternal( + String url, { + String packageName = 'ask', + String? title, + String? subtitleUrl, + Duration? position, + }) async { + final ok = await invoke('openExternal', { + 'url': url, + 'packageName': packageName, + 'title': ?title, + 'subtitleUrl': ?subtitleUrl, + if (position != null) 'positionMs': position.inMilliseconds, + }); + if (ok != true) { + throw StateError( + 'No installed external video player can open this source.', + ); + } + } +} + +/// Single-owner notification/Cast bridge. The local media_kit player remains the +/// decoder; the Android foreground service never opens a second audio stream. +class PlayerNativeSession { + PlayerNativeSession({ + required this.invoke, + required this.local, + required this.media, + required this.isPlaying, + required this.isCurrent, + this.changed, + this.failure, + this.prepareMedia, + this.revokeMedia, + this._castRequested = false, + }); + Future Function(String, Map?) invoke; + final Future Function(String, Duration?) local; + final Map Function() media; + final bool Function() isPlaying, isCurrent; + final void Function()? changed; + final void Function(String)? failure; + final Future> Function()? prepareMedia; + final Future Function()? revokeMedia; + bool _castRequested; + static PlayerNativeSession? _owner; + bool _released = false, + _loading = false, + _background = false, + _stopped = false; + bool _castAttempted = false; + Future? _loadTail; + bool? _loadPlayState; + int _castRevision = 0; + int? _disconnectedRevision; + bool casting = false, connected = false, playing = false, inPip = false; + Duration position = Duration.zero, duration = Duration.zero; + String deviceName = ''; + bool get active => !_released && identical(_owner, this) && isCurrent(); + void claim() { + _owner = this; + MediaPlatform.channel.setMethodCallHandler(handle); + } + + void release() { + _released = true; + _disconnectedRevision = null; + _castRevision++; + unawaited(_revoke().catchError((Object _) {})); + if (identical(_owner, this)) { + _owner = null; + MediaPlatform.channel.setMethodCallHandler(null); + } + } + + Future handle(MethodCall call) async { + if (!active) return; + if (call.method == 'pipChanged') { + inPip = call.arguments == true; + changed?.call(); + return; + } + if (call.arguments is! Map) return; + final args = Map.from(call.arguments as Map); + if (call.method == 'mediaAction') { + final action = args['action']; + if (action is String && + const [ + 'play', + 'pause', + 'seek', + 'stop', + 'next', + 'previous', + ].contains(action)) { + final ms = args['positionMs']; + if (action == 'seek' && (ms is! num || !ms.isFinite || ms < 0)) return; + await command( + action, + position: ms is num ? Duration(milliseconds: ms.toInt()) : null, + ); + } + } else if (call.method == 'castChanged') { + final wasConnected = connected; + connected = args['connected'] == true; + deviceName = args['deviceName'] is String + ? args['deviceName'] as String + : deviceName; + if (!connected) { + _castAttempted = false; + if (wasConnected) _disconnectedRevision = _castRevision; + _castRevision++; + await _revoke(); + if (casting) { + final resume = playing; + casting = false; + await local('seek', position); + if (!active) return; + await local(resume ? 'play' : 'pause', null); + } + } else if (_castRequested && !casting && !_loading && !_castAttempted) { + await loadCast(); + } else if (casting) { + playing = args['playing'] == true; + final p = args['positionMs'], d = args['durationMs']; + if (p is num && p.isFinite && p >= 0) { + position = Duration(milliseconds: p.toInt()); + } + if (d is num && d.isFinite && d >= 0) { + duration = Duration(milliseconds: d.toInt()); + } + } + if (active) changed?.call(); + } + } + + /// Cancel snapshots synchronously, before a source/subtitle change awaits. + /// This does not grant sharing consent or enqueue a replacement transfer. + Future invalidateCastMedia() { + _castRevision++; + _disconnectedRevision = null; + return _revoke(); + } + + Future loadCast() { + if (!active) return Future.value(); + _disconnectedRevision = null; + _castAttempted = true; + _castRequested = true; + final revision = ++_castRevision; + _loadPlayState ??= isPlaying(); + final previous = _loadTail; + final work = previous == null + ? Future.microtask(() => _runCastLoad(revision)) + : previous.then((_) => _runCastLoad(revision)); + late final Future tail; + tail = work.catchError((Object _) {}).whenComplete(() { + if (identical(_loadTail, tail)) { + _loadTail = null; + _loadPlayState = null; + } + }); + _loadTail = tail; + return work; + } + + Future _runCastLoad(int revision) async { + if (!active || revision != _castRevision || !connected) return; + _loading = true; + final wasPlaying = _loadPlayState ?? isPlaying(); + var paused = false; + try { + final payload = Map.from( + prepareMedia == null ? media() : await prepareMedia!(), + ); + if (!active || revision != _castRevision) { + await _revoke(); + return; + } + await local('pause', null); + paused = true; + if (!active || revision != _castRevision) { + await _revoke(); + await _restoreDisconnectedLoad(revision); + return; + } + final ok = await invoke('castLoad', payload); + if (!active) return; + if (revision != _castRevision) { + if (_stopped) await invoke('castCommand', {'action': 'stop'}); + await _revoke(); + await _restoreDisconnectedLoad(revision); + return; + } + if (ok != true) throw StateError('Cast load rejected'); + casting = true; + playing = _loadPlayState ?? wasPlaying; + position = Duration( + milliseconds: (payload['positionMs'] as num?)?.toInt() ?? 0, + ); + if (!playing) await invoke('castCommand', {'action': 'pause'}); + // A remote session must not also advertise a local background session. + if (_background) { + await invoke('backgroundPlaybackStop', null); + _background = false; + } + } catch (error) { + await _revoke(); + if (!active || revision != _castRevision) { + if (paused) await _restoreDisconnectedLoad(revision); + return; + } + casting = false; + if (paused) { + await local((_loadPlayState ?? wasPlaying) ? 'play' : 'pause', null); + } + if (active && revision == _castRevision) { + failure?.call( + error is FormatException || error is UnsupportedError + ? 'This media or subtitle format cannot be shared with the Cast device. Local playback restored.' + : 'Could not load this video on the Cast device. Local playback restored.', + ); + } + } finally { + _loading = false; + if (active) changed?.call(); + } + } + + Future _revoke() async { + await revokeMedia?.call(); + } + + Future _restoreDisconnectedLoad(int revision) async { + // Only disconnect may restore an interrupted transfer. A newer media load, + // stop or owner must never be undone by the old decoder pause completing. + if (active && + !connected && + !_stopped && + _disconnectedRevision == revision) { + await local((_loadPlayState ?? isPlaying()) ? 'play' : 'pause', null); + } + } + + Future command( + String action, { + Duration? position, + double? value, + }) async { + if (!active) return; + if (_loadTail != null && (action == 'play' || action == 'pause')) { + _loadPlayState = action == 'play'; + } + if (action == 'play') _stopped = false; + if (action == 'stop') { + _stopped = true; + _disconnectedRevision = null; + _castRequested = false; + _castRevision++; + await stopBackground(); + if (!active) return; + try { + if (casting) await invoke('castCommand', {'action': 'stop'}); + await local('stop', null); + } finally { + casting = false; + await _revoke(); + if (active) changed?.call(); + } + return; + } + if (casting && + const ['play', 'pause', 'seek', 'stop', 'volume'].contains(action)) { + await invoke('castCommand', { + 'action': action, + if (position != null) 'positionMs': position.inMilliseconds, + 'value': ?value, + }); + } else { + await local(action, position); + } + } + + Future picker() async { + if (!active) return false; + _castRequested = true; + return await invoke('castShowPicker', null) == true; + } + + Future disconnect() async { + if (!active) return; + _castRequested = false; + await invoke('castDisconnect', null); + if (active) { + await handle(const MethodCall('castChanged', {'connected': false})); + } + } + + Future refreshCast() async { + if (!active) return; + final state = await invoke('castGetState', null); + if (active && state is Map) await handle(MethodCall('castChanged', state)); + } + + Future background({ + required bool enabled, + required bool playing, + required Duration position, + required Duration duration, + }) async { + if (!active) return; + if (!enabled || casting || _stopped) { + if (_background) { + await invoke('backgroundPlaybackStop', null); + _background = false; + } + return; + } + await invoke( + _background ? 'backgroundPlaybackUpdate' : 'backgroundPlayback', + { + if (!_background) 'enabled': true, + 'title': media()['title'] ?? '', + 'playing': playing, + 'positionMs': position.inMilliseconds, + 'durationMs': duration.inMilliseconds, + }, + ); + _background = true; + } + + Future stopBackground() async { + if (_background) { + await invoke('backgroundPlaybackStop', null); + _background = false; + } + } +} diff --git a/lib/media/native_playback_page.dart b/lib/media/native_playback_page.dart new file mode 100644 index 0000000..a23e53c --- /dev/null +++ b/lib/media/native_playback_page.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../data/content_policy.dart'; +import '../data/models.dart'; +import '../state/app_state.dart'; +import 'cast_media_share.dart'; +import 'media_helpers.dart'; +import 'media_platform.dart'; +import 'native_player.dart'; + +/// Android playback uses a standalone LibVLC activity. Its exit future owns the +/// checkpoint and any device-only offline-package HTTP lease. +class NativePlaybackPage extends StatefulWidget { + const NativePlaybackPage({ + super.key, + required this.title, + required this.source, + this.episode, + this.playlist = const [], + this.alternatives = const [], + this.localFile, + }); + final TitleRecord title; + final MediaSource source; + final EpisodeRecord? episode; + final List playlist; + final List alternatives; + final String? localFile; + @override + State createState() => _NativePlaybackPageState(); +} + +class _NativePlaybackPageState extends State { + late MediaSource _source = widget.source; + late EpisodeRecord? _episode = widget.episode; + AppState? _app; + bool _started = false, _opening = false; + String? _error; + int _generation = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _app = AppScope.read(context); + if (!_started) { + _started = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_open()); + }); + } + } + + bool _current(int generation) => mounted && generation == _generation; + Duration _resume(EpisodeRecord? episode) { + final app = _app!; + final saved = app.history + .where( + (entry) => + entry.title.libraryKey == widget.title.libraryKey && + entry.episodeId == episode?.id, + ) + .firstOrNull; + if (!app.prefs.resumePlayback || saved == null) return Duration.zero; + return resumePosition( + position: Duration(milliseconds: (saved.positionSeconds * 1000).round()), + duration: Duration(milliseconds: (saved.durationSeconds * 1000).round()), + rewindSeconds: app.prefs.resumeRewindSeconds, + ); + } + + Future _open() async { + if (_opening) return; + final generation = ++_generation; + setState(() { + _opening = true; + _error = null; + }); + try { + while (_current(generation)) { + final source = _source; + final episode = _episode; + if (!ContentPolicy.allowsTitleJson(widget.title.toJson()) || + !ContentPolicy.allowsUrl(source.url)) { + throw StateError('This source is excluded from ReelBox.'); + } + final prefs = _app!.prefs; + var target = widget.localFile ?? source.url; + CastMediaLease? lease; + // Native HLS/DASH reads need a device-only HTTP origin for package + // resources. Never bind a LAN interface or relay remote movie content. + if (widget.localFile != null && + RegExp( + r'\.(m3u8|mpd)$', + caseSensitive: false, + ).hasMatch(widget.localFile!)) { + lease = await CastMediaShare.prepare( + payload: {'url': source.url}, + localFile: widget.localFile, + bindAddress: InternetAddress.loopbackIPv4, + advertisedHost: '127.0.0.1', + ); + + if (!_current(generation)) { + await lease.close(); + return; + } + target = lease.payload['url'] as String; + } + NativePlaybackResult result; + try { + result = await NativePlayerBridge.open( + url: target, + title: episode == null + ? widget.title.title + : '${widget.title.title} — ${episode.title}', + position: _resume(episode), + autoplay: prefs.autoplay, + externalPackage: prefs.externalPlayerPackage, + orientation: prefs.orientation, + videoFit: prefs.videoFit, + speed: prefs.playbackSpeed, + subtitleEnabled: prefs.subtitleEnabled, + ); + } finally { + // The pending native call owns this lease, even if the Flutter route + // was removed. Revoke only after the activity has exited. + await lease?.close(); + } + if (!_current(generation)) return; + // Launch success or an initial zero position is not evidence of viewing. + if (result.opened && + result.position > Duration.zero && + !source.isTrailer) { + await _app!.recordWatch( + widget.title, + episodeId: episode?.id, + episodeTitle: episode?.title ?? '', + sourceUrl: source.url, + positionSeconds: result.position.inMilliseconds / 1000, + durationSeconds: result.duration.inMilliseconds / 1000, + ); + } + if (!_current(generation)) return; + if (!result.opened) { + throw StateError('The native decoder did not open this source.'); + } + final next = + result.completed && prefs.autoNext && widget.localFile == null + ? nextEpisode(widget.playlist, episode?.id) + : null; + final nextSource = next == null + ? null + : selectSource(next.sources, preferredQuality: source.quality); + if (next != null && nextSource != null) { + setState(() { + _episode = next; + _source = nextSource; + }); + continue; + } + setState(() => _opening = false); + if ((!result.completed || prefs.closeOnCompletion) && mounted) { + await Navigator.maybePop(context); + } + return; + } + } catch (_) { + if (_current(generation)) { + setState(() { + _opening = false; + _error = tr( + context, + 'Could not play this source. Retry, choose another quality, or use an external player.', + 'پخش این منبع ممکن نشد. دوباره تلاش کنید، کیفیت دیگری انتخاب کنید یا از پخش‌کننده خارجی استفاده کنید.', + ); + }); + } + } + } + + Future _external() async { + try { + if (widget.localFile != null && + RegExp( + r'\.(m3u8|mpd)$', + caseSensitive: false, + ).hasMatch(widget.localFile!)) { + throw UnsupportedError('Use the in-app player for an offline package.'); + } + await MediaPlatform.openExternal( + widget.localFile ?? _source.url, + packageName: _app!.prefs.externalPlayerPackage, + title: widget.title.title, + position: _resume(_episode), + ); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + tr( + context, + 'No external player could open this source.', + 'پخش‌کننده خارجی نتوانست این منبع را باز کند.', + ), + ), + ), + ); + } + } + } + + @override + void dispose() { + _generation++; + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(_episode?.title ?? widget.title.title)), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + if (_opening) const Center(child: CircularProgressIndicator()), + const SizedBox(height: 24), + if (_error != null) + Text( + tr(context, 'Unable to play this source', 'پخش این منبع ممکن نیست'), + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + _error ?? + tr( + context, + _opening + ? 'Opening the embedded VLC player' + : 'Playback finished', + _opening + ? 'در حال باز کردن پخش‌کننده داخلی VLC' + : 'پخش پایان یافت', + ), + ), + const SizedBox(height: 24), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: _opening ? null : _open, + icon: const Icon(Icons.play_arrow), + label: Text( + tr( + context, + _error == null ? 'Play again' : 'Retry', + _error == null ? 'پخش دوباره' : 'تلاش دوباره', + ), + ), + ), + TextButton.icon( + onPressed: _opening ? null : _external, + icon: const Icon(Icons.open_in_new), + label: Text(tr(context, 'External player', 'پخش‌کننده خارجی')), + ), + ], + ), + if (!_opening && widget.localFile == null) + for (final source in (_episode?.sources ?? widget.alternatives).where( + (source) => + source.url != _source.url && + !source.isTrailer && + isRemoteMedia(source.url) && + ContentPolicy.allowsUrl(source.url), + )) + ListTile( + title: Text( + source.quality.isEmpty + ? tr(context, 'Another source', 'منبع دیگر') + : source.quality, + ), + trailing: const Icon(Icons.play_arrow), + onTap: () { + _source = source; + unawaited(_open()); + }, + ), + ], + ), + ); +} diff --git a/lib/media/native_player.dart b/lib/media/native_player.dart new file mode 100644 index 0000000..a4dec1d --- /dev/null +++ b/lib/media/native_player.dart @@ -0,0 +1,114 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Exit checkpoint from the embedded Android LibVLC activity. `opened` means +/// the decoder actually reached Playing, not merely that Android opened a view. +class NativePlaybackResult { + const NativePlaybackResult({ + required this.position, + required this.duration, + required this.opened, + required this.completed, + }); + + final Duration position, duration; + final bool opened, completed; + + factory NativePlaybackResult.fromMap(Map value) { + int milliseconds(String key) { + final raw = value[key]; + // Duration stores microseconds in a signed 64-bit integer on Android. + if (raw is! int || + raw < 0 || + raw > 0x7fffffffffffffff ~/ Duration.microsecondsPerMillisecond) { + throw FormatException('Invalid native playback $key'); + } + return raw; + } + + final position = milliseconds('positionMs'); + final duration = milliseconds('durationMs'); + if (value['opened'] is! bool || value['completed'] is! bool) { + throw const FormatException('Invalid native playback flags'); + } + // An EOF flag alone must not advance the playlist. Validate the raw timing + // before its display bounds are applied, including unknown-duration input. + final atEnd = + duration > 0 && + position > 0 && + position <= duration && + duration - position <= (duration ~/ 50).clamp(1, 2000); + return NativePlaybackResult( + position: Duration( + milliseconds: duration > 0 && position > duration ? duration : position, + ), + duration: Duration(milliseconds: duration), + opened: value['opened'] as bool, + completed: value['completed'] == true && value['opened'] == true && atEnd, + ); + } +} + +/// Standalone native player, independent of the Flutter PlayerPage/decoder. +/// +/// The future resolves ONLY when the activity exits. Keep any offline loopback +/// server lease alive until then; source URLs are forwarded byte-for-byte. +/// Callers own history persistence, ID association and local lease cleanup. +/// Validation/platform failures may throw. If Android cannot launch the activity, +/// the result has `opened: false`; no path fabricates successful playback. +class NativePlayerBridge { + static const _channel = MethodChannel('org.reelbox/media'); + + static bool get supported => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + static Future open({ + required String url, + required String title, + Duration position = Duration.zero, + bool autoplay = true, + String externalPackage = 'ask', + String orientation = 'auto', + String videoFit = 'contain', + double speed = 1.0, + bool subtitleEnabled = true, + String? subtitleUrl, + bool hardwareAcceleration = true, + bool pipEnabled = true, + }) async { + if (!supported) { + throw UnsupportedError('The embedded native player requires Android.'); + } + if (url.trim().isEmpty) throw ArgumentError.value(url, 'url'); + if (!speed.isFinite || speed < .25 || speed > 3) { + throw ArgumentError.value(speed, 'speed', 'Must be between 0.25 and 3'); + } + if (!const ['auto', 'portrait', 'landscape'].contains(orientation)) { + throw ArgumentError.value(orientation, 'orientation'); + } + if (!const ['contain', 'cover', 'fill'].contains(videoFit)) { + throw ArgumentError.value(videoFit, 'videoFit'); + } + final response = await _channel.invokeMapMethod( + 'openNativePlayer', + { + 'url': url, + 'title': title, + 'positionMs': position.inMilliseconds < 0 ? 0 : position.inMilliseconds, + 'autoplay': autoplay, + 'externalPackage': externalPackage, + 'orientation': orientation, + 'videoFit': videoFit, + 'speed': speed, + 'subtitleEnabled': subtitleEnabled, + 'subtitleUrl': ?subtitleUrl, + 'hardwareAcceleration': hardwareAcceleration, + 'pipEnabled': pipEnabled, + }, + ); + if (response == null) { + throw const FormatException('Native player returned no exit checkpoint'); + } + return NativePlaybackResult.fromMap(response); + } +} diff --git a/lib/media/player_page.dart b/lib/media/player_page.dart new file mode 100644 index 0000000..03e6435 --- /dev/null +++ b/lib/media/player_page.dart @@ -0,0 +1,2007 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import '../data/models.dart'; +import '../state/app_state.dart'; +import 'media_helpers.dart'; +import 'media_platform.dart'; +import 'cast_media_share.dart'; +import 'native_player.dart'; +import 'native_playback_page.dart'; + +class PlayerPage extends StatefulWidget { + const PlayerPage({ + super.key, + required this.title, + required this.source, + this.episode, + this.playlist = const [], + this.alternatives = const [], + this.localFile, + this.playerFactory, + this.videoControllerFactory, + this.videoSurfaceBuilder, + this.castShareFactory, + }); + final TitleRecord title; + final MediaSource source; + final EpisodeRecord? episode; + final List playlist; + final List alternatives; + final String? localFile; + + /// Injectable engine boundaries for deterministic control/lifecycle tests. + @visibleForTesting + final Player Function()? playerFactory; + @visibleForTesting + final VideoController? Function(Player)? videoControllerFactory; + @visibleForTesting + final Widget Function(BoxFit)? videoSurfaceBuilder; + @visibleForTesting + final Future Function({ + required Map payload, + String? localFile, + String? subtitleText, + })? + castShareFactory; + @override + State createState() => + // Choose one engine for the route lifetime; injected engines retain the + // existing desktop/test lifecycle without initializing Android LibVLC. + // ignore: no_logic_in_create_state + NativePlayerBridge.supported && playerFactory == null + ? _AndroidPlayerPageState() + : _PlayerPageState(); +} + +class _AndroidPlayerPageState extends State { + @override + Widget build(BuildContext context) => NativePlaybackPage( + title: widget.title, + source: widget.source, + episode: widget.episode, + playlist: widget.playlist, + alternatives: widget.alternatives, + localFile: widget.localFile, + ); +} + +class _PlayerPageState extends State with WidgetsBindingObserver { + Player? _player; + VideoController? _video; + AppState? _app; + late MediaSource _source = widget.source; + late EpisodeRecord? _episode = widget.episode; + final _subscriptions = >[]; + Timer? _saveTimer, _hideTimer; + final _checkpoint = PlaybackCheckpoint(); + final _systemControls = PlayerSystemControls(); + Duration get _position => + _native?.casting == true ? _native!.position : _checkpoint.position; + set _position(Duration value) => _checkpoint.position = value; + Duration get _duration => + _native?.casting == true ? _native!.duration : _checkpoint.duration; + set _duration(Duration value) => _checkpoint.duration = value; + bool _started = false, _busy = true, _playing = false, _locked = false; + bool _controls = true, _subtitles = true, _audioOnly = false, _pip = false; + String _videoFit = 'contain'; + bool get _fill => _videoFit == 'cover'; + BoxFit get _videoBoxFit => switch (_videoFit) { + 'fill' => BoxFit.fill, + 'cover' => BoxFit.cover, + _ => BoxFit.contain, + }; + String? _error; + PlayerNativeSession? _native; + String? _subtitleUrl; + String? _subtitleText; + SubtitleTrack _selectedSubtitleTrack = SubtitleTrack.auto(); + final _loadedSubtitleTracks = {}; + int _subtitleSelectionRevision = 0; + CastMediaLease? _castLease; + int _shareGeneration = 0; + bool _muted = false, _fullscreen = true, _settingsOpen = false; + double _castVolume = .5; + bool get _casting => _native?.casting == true; + bool get _effectivePlaying => _casting ? _native!.playing : _playing; + String get _mediaTitle => _episode == null + ? widget.title.title + : '${widget.title.title} — ${_episode!.title}'; + Map _mediaPayload() => { + 'url': widget.localFile ?? _source.url, + 'title': _mediaTitle, + 'contentType': mediaContentType(_source.url), + 'positionMs': _position.inMilliseconds, + }; + Future> _prepareCastMedia() async { + final generation = ++_shareGeneration; + final payload = _mediaPayload(); + final text = _subtitles ? _subtitleText : null; + final previous = _castLease; + _castLease = null; + await previous?.close(); + final prepare = widget.castShareFactory ?? CastMediaShare.prepare; + final lease = await prepare( + payload: payload, + localFile: widget.localFile, + subtitleText: text, + ); + if (!mounted || !_lifecycle.ownsSystem || generation != _shareGeneration) { + await lease.close(); + throw StateError('The Cast request is no longer current.'); + } + _castLease = lease; + return lease.payload; + } + + Future _revokeCastMedia() async { + _shareGeneration++; + final lease = _castLease; + _castLease = null; + await lease?.close(); + } + + Future _invalidateCastMedia() => + _native?.invalidateCastMedia() ?? _revokeCastMedia(); + + late final _lifecycle = PlaybackLifecycle(isMounted: () => mounted); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _app = AppScope.read(context); + if (!_started) { + _started = true; + _subtitles = _app!.prefs.subtitleEnabled; + _videoFit = _app!.prefs.videoFit; + _audioOnly = _app!.prefs.audioOnly; + unawaited(_initialize()); + } + } + + void _refresh() { + if (mounted) setState(() {}); + } + + void _guard(Future operation) { + unawaited( + operation.catchError((Object error) { + if (mounted) { + setState(() { + _error = + 'The media server or player could not complete this operation.'; + _busy = false; + _controls = true; + }); + } + }), + ); + } + + Future _initialize() async { + if (widget.localFile == null && !isRemoteMedia(_source.url)) { + if (mounted) { + setState(() { + _error = 'Only HTTP(S) media sources can be played.'; + _busy = false; + }); + } + return; + } + try { + WidgetsBinding.instance.addObserver(this); + final player = _player = widget.playerFactory?.call() ?? Player(); + _video = widget.videoControllerFactory != null + ? widget.videoControllerFactory!(player) + : VideoController(player); + _subscriptions.add( + player.stream.position.listen((value) { + if (_casting) return; + _position = value; + _refresh(); + }), + ); + _subscriptions.add( + player.stream.duration.listen((value) { + if (_casting) return; + _duration = value; + _refresh(); + }), + ); + _subscriptions.add( + player.stream.playing.listen((value) { + _playing = value; + if (_casting) return; + if (!value) _controls = true; + _refresh(); + if (value) _scheduleHide(); + _guard(_syncBackground()); + }), + ); + _subscriptions.add( + player.stream.buffering.listen((value) { + if (_casting) return; + _busy = value; + _refresh(); + }), + ); + _subscriptions.add(player.stream.tracks.listen((_) => _refresh())); + _subscriptions.add( + player.stream.error.listen((message) { + if (mounted) { + setState(() { + _error = 'The media server is unavailable or this source cannot be decoded.'; + _busy = false; + _controls = true; + }); + } + }), + ); + _subscriptions.add( + player.stream.completed.listen((completed) { + if (completed && !_casting) _guard(_completed()); + }), + ); + _saveTimer = Timer.periodic(const Duration(seconds: 10), (_) { + if (_effectivePlaying) _guard(_save()); + _guard(_syncBackground()); + }); + final ready = await _lifecycle.setup([ + if (MediaPlatform.supported) + () async { + _native = PlayerNativeSession( + invoke: (method, args) => + MediaPlatform.invoke(method, args), + local: _localCommand, + media: _mediaPayload, + prepareMedia: _prepareCastMedia, + revokeMedia: _revokeCastMedia, + isPlaying: () => _effectivePlaying, + isCurrent: () => _lifecycle.ownsSystem, + changed: () { + _pip = _native!.inPip; + if (!_effectivePlaying) _controls = true; + _refresh(); + }, + failure: _notice, + )..claim(); + await _systemControls.capture(); + }, + if (MediaPlatform.supported) + () async { + _brightness = _systemControls.brightness; + _volume = _systemControls.volume; + await MediaPlatform.setActive(true); + }, + () => SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), + _orientation, + ]); + if (!ready) return; + await _open(_source, episode: _episode); + if (_lifecycle.ownsSystem && MediaPlatform.supported) { + try { + await _native?.refreshCast(); + } catch (_) { + /* Cast unavailable on this device. */ + } + } + } catch (_) { + if (mounted) { + setState(() { + _error = 'The player could not initialize on this device.'; + _busy = false; + }); + } + } + } + + Future _orientation() async { + final mode = _app!.prefs.orientation; + await SystemChrome.setPreferredOrientations( + mode == 'landscape' + ? [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] + : mode == 'portrait' + ? [DeviceOrientation.portraitUp] + : DeviceOrientation.values, + ); + } + + Future _open( + MediaSource source, { + EpisodeRecord? episode, + Duration? position, + }) async { + if (_player == null) return; + if (widget.localFile == null && !isRemoteMedia(source.url)) { + throw StateError('Invalid source'); + } + _subtitleSelectionRevision++; + final revocation = _invalidateCastMedia(); + await _lifecycle.open( + save: () async { + await revocation; + await _save(); + }, + dispatch: (current) async { + final episodeChanged = _episode?.id != episode?.id; + _source = source; + _episode = episode; + _subtitleUrl = null; + _subtitleText = null; + _selectedSubtitleTrack = SubtitleTrack.auto(); + if (episodeChanged) _loadedSubtitleTracks.clear(); + _error = null; + _busy = true; + _checkpoint.beginOpen(); + _refresh(); + final saved = _app!.history + .where( + (e) => + e.title.libraryKey == widget.title.libraryKey && + e.episodeId == episode?.id, + ) + .firstOrNull; + final start = + position ?? + (_app!.prefs.resumePlayback && saved != null + ? resumePosition( + position: Duration( + milliseconds: (saved.positionSeconds * 1000).round(), + ), + duration: Duration( + milliseconds: (saved.durationSeconds * 1000).round(), + ), + rewindSeconds: _app!.prefs.resumeRewindSeconds, + ) + : Duration.zero); + if (!current()) return; + await dispatchMediaOpen( + open: _player!.open, + uri: widget.localFile ?? source.url, + start: start, + autoplay: !_casting && _app!.prefs.autoplay, + ); + if (!mounted || !current()) return; + _checkpoint.didOpen(); + await _applyOptions(current: current); + if (!current()) return; + if (_casting) { + _native!.position = start; + await _native!.loadCast(); + } + if (!current()) return; + await _applyDelays(current: current); + if (!current()) return; + _busy = false; + _refresh(); + _scheduleHide(); + }, + ); + } + + Future _applyDelays({bool Function()? current}) async { + if (current != null && !current()) return; + final platform = _player?.platform; + if (platform is NativePlayer) { + await platform.setProperty('sub-delay', '${_app!.prefs.subtitleDelay}'); + if (current != null && !current()) return; + await platform.setProperty('audio-delay', '${_app!.prefs.audioDelay}'); + } + } + + Future _save() async { + if (_player == null || + _source.isTrailer || + _app == null || + !(_checkpoint.canPersist || (_casting && _position > Duration.zero))) { + return; + } + await _app!.recordWatch( + widget.title, + episodeId: _episode?.id, + episodeTitle: _episode?.title ?? '', + sourceUrl: _source.url, + positionSeconds: _position.inMilliseconds / 1000, + durationSeconds: _duration.inMilliseconds / 1000, + ); + } + + Future _next() async { + final episode = nextEpisode(widget.playlist, _episode?.id); + if (episode == null) return; + final source = selectSource( + episode.sources, + preferredQuality: _source.quality, + ); + if (source == null) throw StateError('No source for the next episode'); + await _open(source, episode: episode, position: Duration.zero); + } + + Future _previous() async { + final i = widget.playlist.indexWhere((e) => e.id == _episode?.id); + if (i <= 0) return; + final episode = widget.playlist[i - 1]; + final source = selectSource( + episode.sources, + preferredQuality: _source.quality, + ); + if (source != null) await _open(source, episode: episode); + } + + void _seek(int direction) { + if (_locked || widget.title.isChannel || _player == null) return; + final value = + _position + Duration(seconds: direction * _app!.prefs.skipSeconds); + _guard( + _command( + 'seek', + position: Duration( + milliseconds: value.inMilliseconds.clamp( + 0, + _duration.inMilliseconds > 0 ? _duration.inMilliseconds : 1 << 40, + ), + ), + ), + ); + _showControls(); + } + + void _scheduleHide() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(seconds: 4), () { + if (mounted && _effectivePlaying && !_locked && !_settingsOpen) { + setState(() => _controls = false); + } + }); + } + + void _showControls() { + if (mounted) setState(() => _controls = true); + _scheduleHide(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) _guard(_background()); + if (state == AppLifecycleState.resumed && + MediaPlatform.supported && + _lifecycle.ownsSystem) { + _guard(_native!.refreshCast()); + } + } + + Future _background() async { + await _save(); + final inPip = + MediaPlatform.supported && + (await MediaPlatform.invoke('isInPip') ?? false); + if (!_lifecycle.ownsSystem) return; + if (_casting || _app!.prefs.backgroundAudio) { + await _syncBackground(); + } else if (!inPip) { + await _player?.pause(); + } + } + + @override + void dispose() { + _saveTimer?.cancel(); + _hideTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + _native?.release(); + for (final sub in _subscriptions) { + unawaited(sub.cancel()); + } + unawaited( + _lifecycle + .dispose(() async { + if (_player == null) return; + await _native?.stopBackground(); + await Future.wait([ + _systemControls.restore(), + MediaPlatform.setActive(false), + SystemChrome.setPreferredOrientations(DeviceOrientation.values), + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge), + ]); + }) + .catchError((_) {}), + ); + if (_player != null) { + unawaited(_save().catchError((_) {})); + unawaited(_player!.dispose()); + } + super.dispose(); + } + + String _clock(Duration duration) => duration.toString().split('.').first; + @override + Widget build(BuildContext context) { + final prefs = AppScope.of(context).prefs; + final alternatives = (_episode?.sources ?? widget.alternatives) + .where((s) => !s.isTrailer) + .toList(); + return Focus( + // The inner PlayerInteraction focus must own keyboard events; an + // autofocus ancestor prevents its CallbackShortcuts from seeing them. + autofocus: false, + child: PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) _guard(_save()); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Theme( + data: ThemeData.dark(useMaterial3: true), + child: PlayerInteraction( + locked: _locked, + controlsVisible: _controls || !_effectivePlaying, + onAction: _action, + child: Stack( + fit: StackFit.expand, + children: [ + if (widget.videoSurfaceBuilder != null && + !_audioOnly && + !_casting) + widget.videoSurfaceBuilder!(_videoBoxFit) + else if (_video != null && !_audioOnly && !_casting) + Video( + controller: _video!, + controls: NoVideoControls, + fit: _videoBoxFit, + pauseUponEnteringBackgroundMode: false, + subtitleViewConfiguration: SubtitleViewConfiguration( + visible: _subtitles, + style: TextStyle( + fontFamily: prefs.subtitleFontFamily, + fontSize: prefs.subtitleFontSize, + color: Color(prefs.subtitleColor), + backgroundColor: Color(prefs.subtitleBackground), + height: 1.4, + ), + padding: EdgeInsets.fromLTRB( + 18, + 0, + 18, + _controls ? 150 : 28, + ), + ), + ), + if (_audioOnly || _casting) + Center( + child: Icon( + _casting + ? Icons.cast_connected + : Icons.headphones_rounded, + color: Colors.white54, + size: 100, + ), + ), + // Surface gestures sit below controls, not above them in the + // double-tap arena, so transport buttons respond immediately. + PlayerSurfaceGestures(locked: _locked, onAction: _action), + if (!_effectivePlaying && + !_busy && + _error == null && + !_pip && + !_locked) + Center( + child: IconButton.filledTonal( + tooltip: 'Play', + iconSize: 48, + onPressed: () => _guard(_command('play')), + icon: const Icon(Icons.play_arrow), + ), + ), + if (_casting) + Positioned( + top: 80, + left: 24, + right: 24, + child: Text( + 'Casting to ${_native!.deviceName}', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + if (_busy && _error == null) + const Center(child: CircularProgressIndicator()), + if (_error != null) + Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: Colors.white70, + size: 44, + ), + const SizedBox(height: 16), + Text( + tr( + context, + 'Unable to play this source', + 'پخش این منبع ممکن نیست', + ), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + ), + ), + const SizedBox(height: 10), + Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 18), + FilledButton( + onPressed: () => _player == null + ? _guard(_initialize()) + : _guard(_open(_source, episode: _episode)), + child: Text(tr(context, 'Retry', 'تلاش دوباره')), + ), + if (MediaPlatform.supported && + isRemoteMedia(_source.url)) + TextButton( + onPressed: () => _guard(_externalPlayer()), + child: Text( + tr( + context, + 'Try an external player', + 'پخش‌کننده خارجی', + ), + ), + ), + ], + ), + ), + ), + if ((_controls || _error != null) && !_locked && !_pip) + SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: Row( + children: [ + BackButton(color: Colors.white), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.title.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + if (_episode != null) + Text( + _episode!.title, + maxLines: 1, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ), + ), + IconButton( + tooltip: tr( + context, + 'Lock controls', + 'قفل کنترل‌ها', + ), + onPressed: () => setState(() => _locked = true), + icon: const Icon(Icons.lock_outline), + ), + if (MediaPlatform.supported) + IconButton( + tooltip: _casting + ? 'Cast controls' + : 'Cast to device', + onPressed: () => _guard(_castControls()), + icon: Icon( + _casting + ? Icons.cast_connected + : Icons.cast, + ), + ), + IconButton( + tooltip: 'Fullscreen', + onPressed: () => _action('fullscreen'), + icon: Icon( + _fullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ), + if (MediaPlatform.supported && !_casting) + IconButton( + tooltip: 'Picture in picture', + onPressed: () async { + try { + final ok = await MediaPlatform.enterPip(); + if (!ok && mounted) { + _notice( + 'Picture in picture is unavailable on this device.', + ); + } + } catch (_) { + if (mounted) { + _notice( + 'Picture in picture is unavailable.', + ); + } + } + }, + icon: const Icon( + Icons.picture_in_picture_alt, + ), + ), + IconButton( + tooltip: tr( + context, + 'Player settings', + 'تنظیمات پخش', + ), + onPressed: _player == null ? null : _settings, + icon: const Icon(Icons.tune), + ), + ], + ), + ), + ), + ), + if (_controls && + !_locked && + !_pip && + _error == null && + _player != null) + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.fromLTRB(14, 16, 14, 12), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + widget.title.isChannel + ? 'LIVE' + : _clock(_position), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + Expanded( + child: Slider( + value: _duration.inMilliseconds > 0 + ? (_position.inMilliseconds / + _duration.inMilliseconds) + .clamp(0, 1) + : 0, + onChanged: + widget.title.isChannel || + _duration == Duration.zero + ? null + : (v) => _guard( + _command( + 'seek', + position: Duration( + milliseconds: + (_duration.inMilliseconds * + v) + .round(), + ), + ), + ), + ), + ), + Text( + _clock(_duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + tooltip: tr( + context, + 'Previous episode', + 'قسمت قبلی', + ), + onPressed: + widget.playlist.indexWhere( + (e) => e.id == _episode?.id, + ) > + 0 + ? () => _guard(_previous()) + : null, + icon: const Icon(Icons.skip_previous), + ), + IconButton( + tooltip: tr(context, 'Back', 'عقب'), + onPressed: () => _seek(-1), + icon: const Icon(Icons.replay_10), + ), + IconButton.filled( + tooltip: _effectivePlaying + ? 'Pause' + : 'Play', + iconSize: 30, + onPressed: () { + _guard( + _command( + _effectivePlaying ? 'pause' : 'play', + ), + ); + _showControls(); + }, + icon: Icon( + _effectivePlaying + ? Icons.pause + : Icons.play_arrow, + ), + ), + IconButton( + tooltip: tr(context, 'Forward', 'جلو'), + onPressed: () => _seek(1), + icon: const Icon(Icons.forward_10), + ), + IconButton( + tooltip: tr( + context, + 'Next episode', + 'قسمت بعدی', + ), + onPressed: + nextEpisode( + widget.playlist, + _episode?.id, + ) == + null + ? null + : () => _guard(_next()), + icon: const Icon(Icons.skip_next), + ), + IconButton( + tooltip: tr( + context, + 'Fit or fill', + 'اندازه تصویر', + ), + onPressed: () => _action('fit'), + icon: Icon( + _fill + ? Icons.fit_screen + : Icons.aspect_ratio, + ), + ), + ], + ), + if (alternatives.length > 1) + Wrap( + spacing: 8, + children: [ + for (final source in alternatives) + ChoiceChip( + label: Text( + source.quality.isEmpty + ? source.type + : source.quality, + ), + selected: source.url == _source.url, + onSelected: (v) { + if (v) { + _guard( + _open( + source, + episode: _episode, + position: _position, + ), + ); + } + }, + ), + ], + ), + ], + ), + ), + ), + ), + if (_locked && !_pip) + SafeArea( + child: Align( + alignment: Alignment.topRight, + child: IconButton.filledTonal( + tooltip: tr( + context, + 'Unlock controls', + 'باز کردن قفل', + ), + onPressed: () => setState(() { + _locked = false; + _controls = true; + }), + icon: const Icon(Icons.lock_open), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future _applyOptions({bool Function()? current}) async { + final prefs = _app!.prefs; + _subtitles = prefs.subtitleEnabled; + _videoFit = prefs.videoFit; + _audioOnly = prefs.audioOnly; + await applyPlayerOptions( + prefs, + rate: (v) => _player!.setRate(v), + audioOnly: (v) => + _player!.setVideoTrack(v ? VideoTrack.no() : VideoTrack.auto()), + volume: (v) async { + if (MediaPlatform.supported && _lifecycle.ownsSystem) { + await MediaPlatform.invoke('setVolume', {'value': v}); + _volume = v; + } + }, + current: current, + ); + _refresh(); + } + + Future _localCommand(String action, Duration? position) async { + if (!mounted || !_lifecycle.ownsSystem || _player == null) return; + switch (action) { + case 'play': + await _player!.play(); + case 'pause': + await _player!.pause(); + case 'seek': + if (position != null) await _player!.seek(position); + case 'stop': + await _player!.pause(); + await _player!.seek(Duration.zero); + case 'next': + await _next(); + case 'previous': + await _previous(); + } + } + + Future _command(String action, {Duration? position}) async { + if (_native != null) { + await _native!.command(action, position: position); + } else { + await _localCommand(action, position); + } + if (mounted) _showControls(); + } + + Future _syncBackground() async { + if (!MediaPlatform.supported || + !_lifecycle.ownsSystem || + _app == null || + _native == null) { + return; + } + await _native!.background( + enabled: _app!.prefs.backgroundAudio, + playing: _effectivePlaying, + position: _position, + duration: _duration, + ); + } + + Future _setSubtitles(bool enabled) async { + if (enabled && _selectedSubtitleTrack.id == 'no') { + return _selectSubtitleTrack(SubtitleTrack.auto()); + } + final revocation = _invalidateCastMedia(); + final generation = _shareGeneration; + _subtitles = enabled; + _refresh(); + await revocation; + await _app!.updateSettings(_app!.prefs.copyWith(subtitleEnabled: enabled)); + if (mounted && + _lifecycle.ownsSystem && + generation == _shareGeneration && + _casting) { + await _native!.loadCast(); + } + } + + Future _setAudioOnly(bool enabled) async { + _audioOnly = enabled; + _refresh(); + await _app!.updateSettings(_app!.prefs.copyWith(audioOnly: enabled)); + if (!mounted || !_lifecycle.ownsSystem) return; + await _player!.setVideoTrack(enabled ? VideoTrack.no() : VideoTrack.auto()); + await _syncBackground(); + } + + Future _setFit(bool fill) async { + final mode = fill ? 'cover' : 'contain'; + if (_videoFit == mode) return; + _videoFit = mode; + _refresh(); + await _app!.updateSettings( + _app!.prefs.copyWith(videoFit: fill ? 'cover' : 'contain'), + ); + } + + Future _setVolume(double value) async { + value = value.clamp(0, 1); + if (_casting) { + _castVolume = value; + await _native!.command('volume', value: value); + } else if (MediaPlatform.supported && _lifecycle.ownsSystem) { + _volume = value; + await MediaPlatform.invoke('setVolume', {'value': value}); + if (mounted) { + await _app!.updateSettings(_app!.prefs.copyWith(playerVolume: value)); + } + } else if (_player != null) { + _volume = value; + await _player!.setVolume(value * 100); + } + _refresh(); + } + + Future _toggleMute() async { + _muted = !_muted; + if (_casting) { + await _native!.command('volume', value: _muted ? 0 : _castVolume); + } else { + await _player?.setVolume(_muted ? 0 : 100); + } + _refresh(); + } + + Future _setFullscreen(bool value) async { + if (!_lifecycle.ownsSystem) return; + _fullscreen = value; + await SystemChrome.setEnabledSystemUIMode( + value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, + ); + _refresh(); + } + + void _action(String action) { + if (_locked) return; + switch (action) { + case 'toggle': + _guard(_command(_effectivePlaying ? 'pause' : 'play')); + case 'play': + case 'pause': + case 'stop': + case 'next': + case 'previous': + _guard(_command(action)); + case 'back': + _seek(-1); + case 'forward': + _seek(1); + case 'mute': + _guard(_toggleMute()); + case 'subtitles': + _guard(_setSubtitles(!_subtitles)); + case 'fullscreen': + _guard(_setFullscreen(!_fullscreen)); + case 'exitFullscreen': + _guard(_setFullscreen(false)); + case 'fit': + _guard(_setFit(!_fill)); + case 'fill': + _guard(_setFit(true)); + case 'contain': + _guard(_setFit(false)); + case 'volumeUp': + _guard(_setVolume((_casting ? _castVolume : _volume) + .05)); + case 'volumeDown': + _guard(_setVolume((_casting ? _castVolume : _volume) - .05)); + case 'show': + _showControls(); + case 'controls': + if (mounted) setState(() => _controls = !_controls); + if (_controls) _scheduleHide(); + } + } + + Future _completed() async { + await _save(); + if (!mounted || !_lifecycle.ownsSystem) return; + if (_app!.prefs.autoNext && + nextEpisode(widget.playlist, _episode?.id) != null) { + await _next(); + } else if (_app!.prefs.closeOnCompletion) { + Navigator.maybePop(context); + } else { + _controls = true; + _refresh(); + } + } + + Future _reset({required bool subtitlesOnly}) async { + await _app!.updateSettings( + subtitlesOnly + ? resetSubtitleSettings(_app!.prefs) + : resetPlayerSettings(_app!.prefs), + ); + if (!mounted || !_lifecycle.ownsSystem) return; + if (!subtitlesOnly) { + _muted = false; + await _player!.setVolume(100); + await _orientation(); + await _applyOptions(); + await _syncBackground(); + } else { + _subtitles = _app!.prefs.subtitleEnabled; + } + await _applyDelays(); + _refresh(); + } + + Future _castControls() async { + if (!_native!.connected) { + if (!await _native!.picker() && mounted) { + _notice('Cast picker is unavailable on this device.'); + } + return; + } + _settingsOpen = true; + _hideTimer?.cancel(); + await showModalBottomSheet( + context: context, + builder: (sheet) => StatefulBuilder( + builder: (sheet, update) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.cast_connected), + title: Text(_native!.deviceName), + subtitle: Text(_mediaTitle), + ), + Text( + 'Use the player timeline and episode buttons to control the TV.', + ), + if (!_casting) + FilledButton( + onPressed: () async { + await _native!.loadCast(); + if (sheet.mounted) Navigator.pop(sheet); + }, + child: const Text('Cast this video'), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: 'Remote play/pause', + onPressed: !_casting + ? null + : () async { + await _command( + _effectivePlaying ? 'pause' : 'play', + ); + if (sheet.mounted) update(() {}); + }, + icon: Icon( + _effectivePlaying ? Icons.pause : Icons.play_arrow, + ), + ), + IconButton( + tooltip: 'Choose Cast device', + onPressed: () => _guard(_native!.picker().then((_) {})), + icon: const Icon(Icons.cast), + ), + ], + ), + Slider( + value: _castVolume, + onChanged: (v) { + update(() => _castVolume = v); + _guard(_setVolume(v)); + }, + ), + TextButton( + onPressed: () { + Navigator.pop(sheet); + _guard(_native!.disconnect()); + }, + child: const Text('Disconnect and resume here'), + ), + ], + ), + ), + ), + ); + _settingsOpen = false; + _scheduleHide(); + } + + Future _externalPlayer() async { + String? package = _app!.prefs.externalPlayerPackage; + if (package == 'ask') { + package = await showModalBottomSheet( + context: context, + builder: (sheet) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final entry in const { + 'ask': 'Android chooser', + 'org.videolan.vlc': 'VLC', + 'com.mxtech.videoplayer.ad': 'MX Player', + 'com.mxtech.videoplayer.pro': 'MX Player Pro', + 'is.xyz.mpv': 'mpv', + }.entries) + ListTile( + title: Text(entry.value), + onTap: () => Navigator.pop(sheet, entry.key), + ), + ], + ), + ), + ); + } + if (package == null || !mounted || !_lifecycle.ownsSystem) return; + await _save(); + await MediaPlatform.openExternal( + widget.localFile ?? _source.url, + packageName: package, + title: _mediaTitle, + subtitleUrl: _subtitles ? _subtitleUrl : null, + position: _position, + ); + if (mounted && _lifecycle.ownsSystem) await _command('pause'); + } + + void _notice(String message) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } + } + + Future _externalSubtitle() async { + final selection = _subtitleSelectionRevision; + final file = await FilePicker.pickFile( + type: FileType.custom, + allowedExtensions: ['srt', 'vtt', 'ass', 'ssa'], + ); + if (file == null || !mounted || !_lifecycle.ownsSystem) return; + final size = await file.length(); + if (size == null || size > 8 * 1024 * 1024) { + throw StateError('Subtitle file is too large.'); + } + final bytes = await file.readAsBytes(); + if (!mounted || + !_lifecycle.ownsSystem || + selection != _subtitleSelectionRevision) { + return; + } + final data = decodeSubtitle(bytes); + await _selectSubtitleTrack(SubtitleTrack.data(data, title: file.name)); + } + + Future _selectSubtitleTrack( + SubtitleTrack track, { + String? sourceUrl, + }) async { + final revision = ++_subtitleSelectionRevision; + final revocation = _invalidateCastMedia(); + await revocation; + if (!mounted || + !_lifecycle.ownsSystem || + revision != _subtitleSelectionRevision) { + return; + } + await _player!.setSubtitleTrack(track); + if (!mounted || + !_lifecycle.ownsSystem || + revision != _subtitleSelectionRevision) { + return; + } + _selectedSubtitleTrack = track; + _subtitleText = track.data ? track.id : null; + _subtitleUrl = track.data + ? sourceUrl ?? _loadedSubtitleTracks[track] + : null; + if (track.data) { + _loadedSubtitleTracks[track] = _subtitleUrl; + if (_loadedSubtitleTracks.length > 4) { + _loadedSubtitleTracks.remove(_loadedSubtitleTracks.keys.first); + } + } + if (_casting && !track.data && track.id != 'no') { + _notice( + 'Embedded subtitles cannot be shared; Cast continues without them', + ); + } + await _setSubtitles(track.id != 'no'); + } + + Future _settings() async { + _hideTimer?.cancel(); + _settingsOpen = true; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, update) { + final prefs = _app!.prefs; + return SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * .75, + child: ListView( + children: [ + ListTile( + title: Text( + tr(context, 'Player settings', 'تنظیمات پخش'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + trailing: IconButton( + onPressed: () => Navigator.pop(sheetContext), + icon: const Icon(Icons.close), + ), + ), + SwitchListTile( + title: const Text('Mute'), + value: _muted, + onChanged: (_) async { + await _toggleMute(); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Fill video frame'), + value: _fill, + onChanged: (v) async { + await _setFit(v); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Remember system volume'), + subtitle: const Text( + 'Only when enabled, restore saved volume when opening media.', + ), + value: prefs.rememberVolume, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith( + rememberVolume: v, + playerVolume: _volume, + ), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Background audio'), + subtitle: const Text( + 'Keep audio playing with notification and lock-screen controls.', + ), + value: prefs.backgroundAudio, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith(backgroundAudio: v), + ); + await _syncBackground(); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Autoplay when opening media'), + value: prefs.autoplay, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith(autoplay: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Automatically play next episode'), + value: prefs.autoNext, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith(autoNext: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Close player on completion'), + value: prefs.closeOnCompletion, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith(closeOnCompletion: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + SwitchListTile( + title: const Text('Resume previous position'), + value: prefs.resumePlayback, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith(resumePlayback: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ListTile( + title: const Text('Resume rewind'), + trailing: DropdownButton( + value: prefs.resumeRewindSeconds, + items: [ + for (final v in { + ...[0, 5, 10, 15, 30, 60, 120], + prefs.resumeRewindSeconds, + }) + DropdownMenuItem(value: v, child: Text('$v s')), + ], + onChanged: (v) async { + if (v == null) return; + await _app!.updateSettings( + _app!.prefs.copyWith(resumeRewindSeconds: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ), + ListTile( + title: const Text('Subtitle font'), + trailing: DropdownButton( + value: prefs.subtitleFontFamily, + items: [ + for (final v in { + 'sans-serif', + 'serif', + 'monospace', + prefs.subtitleFontFamily, + }) + DropdownMenuItem(value: v, child: Text(v)), + ], + onChanged: (v) async { + if (v == null) return; + await _app!.updateSettings( + _app!.prefs.copyWith(subtitleFontFamily: v), + ); + _refresh(); + if (sheetContext.mounted) update(() {}); + }, + ), + ), + ListTile( + title: const Text('Subtitle color'), + trailing: Wrap( + children: [ + for (final c in [0xFFFFFFFF, 0xFFFFFF00, 0xFF00FFFF]) + IconButton( + tooltip: c == 0xFFFFFFFF + ? 'White subtitles' + : c == 0xFFFFFF00 + ? 'Yellow subtitles' + : 'Cyan subtitles', + icon: Icon(Icons.circle, color: Color(c)), + onPressed: () async { + await _app!.updateSettings( + _app!.prefs.copyWith(subtitleColor: c), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ], + ), + ), + SwitchListTile( + title: const Text('Subtitle background'), + value: prefs.subtitleBackground != 0, + onChanged: (v) async { + await _app!.updateSettings( + _app!.prefs.copyWith( + subtitleBackground: v ? 0x80000000 : 0, + ), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ListTile( + title: const Text('Reset subtitle styling and timing'), + onTap: () async { + await _reset(subtitlesOnly: true); + if (sheetContext.mounted) update(() {}); + }, + ), + ListTile( + title: const Text('Reset all player options'), + subtitle: const Text( + 'Does not clear your library or playback history.', + ), + onTap: () async { + await _reset(subtitlesOnly: false); + if (sheetContext.mounted) update(() {}); + }, + ), + ListTile( + title: const Text('External player preference'), + trailing: DropdownButton( + value: prefs.externalPlayerPackage, + items: [ + for (final e in const { + 'ask': 'Ask', + 'org.videolan.vlc': 'VLC', + 'com.mxtech.videoplayer.ad': 'MX Player', + 'com.mxtech.videoplayer.pro': 'MX Pro', + 'is.xyz.mpv': 'mpv', + }.entries) + DropdownMenuItem(value: e.key, child: Text(e.value)), + ], + onChanged: (v) async { + if (v == null) return; + await _app!.updateSettings( + _app!.prefs.copyWith(externalPlayerPackage: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ), + if (MediaPlatform.supported && widget.localFile == null) + ListTile( + leading: const Icon(Icons.video_library_outlined), + title: const Text('Open video in embedded browser'), + onTap: () { + Navigator.pop(sheetContext); + _guard( + MediaPlatform.invoke('openEmbedded', { + 'url': _source.url, + 'title': _mediaTitle, + }).then((ok) { + if (ok != true) { + _notice('Embedded playback is unavailable.'); + } + }), + ); + }, + ), + ListTile( + title: Text(tr(context, 'Speed', 'سرعت')), + trailing: DropdownButton( + value: + [ + 0.5, + .75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + ].contains(prefs.playbackSpeed) + ? prefs.playbackSpeed + : 1.0, + items: [ + for (final rate in [ + 0.5, + .75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + ]) + DropdownMenuItem(value: rate, child: Text('$rate×')), + ], + onChanged: (v) async { + if (v == null) return; + await _player!.setRate(v); + await _app!.updateSettings( + prefs.copyWith(playbackSpeed: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ), + SwitchListTile( + title: Text(tr(context, 'Show subtitles', 'نمایش زیرنویس')), + value: _subtitles, + onChanged: (v) { + _guard(_setSubtitles(v)); + update(() {}); + }, + ), + for (final track in { + ..._loadedSubtitleTracks.keys, + ..._player!.state.tracks.subtitle, + }) + ListTile( + key: ValueKey(track), + leading: const Icon(Icons.subtitles_outlined), + title: Text(track.title ?? track.language ?? track.id), + onTap: () { + Navigator.pop(sheetContext); + _guard(_selectSubtitleTrack(track)); + }, + ), + ListTile( + leading: const Icon(Icons.file_open_outlined), + title: Text( + tr(context, 'Load subtitle file', 'انتخاب فایل زیرنویس'), + ), + onTap: () { + Navigator.pop(sheetContext); + _guard(_externalSubtitle()); + }, + ), + ListTile( + leading: const Icon(Icons.language), + title: Text( + tr( + context, + 'Subtitles from service', + 'زیرنویس‌های سرویس', + ), + ), + onTap: () { + Navigator.pop(sheetContext); + _guard(_remoteSubtitles()); + }, + ), + for (final track in _player!.state.tracks.audio) + ListTile( + leading: const Icon(Icons.audiotrack), + title: Text(track.title ?? track.language ?? track.id), + onTap: () { + Navigator.pop(sheetContext); + _guard(_player!.setAudioTrack(track)); + }, + ), + ListTile( + title: Text(tr(context, 'Subtitle size', 'اندازه زیرنویس')), + subtitle: Slider( + value: prefs.subtitleFontSize.clamp(12, 40), + min: 12, + max: 40, + onChanged: (v) async { + await _app!.updateSettings( + prefs.copyWith(subtitleFontSize: v), + ); + if (sheetContext.mounted) update(() {}); + }, + ), + ), + ListTile( + title: Text( + tr( + context, + 'Subtitle & audio timing', + 'هماهنگی زیرنویس و صدا', + ), + ), + subtitle: Text( + '${prefs.subtitleDelay}s / ${prefs.audioDelay}s', + ), + trailing: Wrap( + children: [ + IconButton( + tooltip: 'Subtitle -0.5s', + onPressed: () async { + await _app!.updateSettings( + prefs.copyWith( + subtitleDelay: prefs.subtitleDelay - .5, + ), + ); + await _applyDelays(); + if (sheetContext.mounted) update(() {}); + }, + icon: const Icon(Icons.remove), + ), + IconButton( + tooltip: 'Subtitle +0.5s', + onPressed: () async { + await _app!.updateSettings( + prefs.copyWith( + subtitleDelay: prefs.subtitleDelay + .5, + ), + ); + await _applyDelays(); + if (sheetContext.mounted) update(() {}); + }, + icon: const Icon(Icons.add), + ), + ], + ), + ), + SwitchListTile( + title: Text( + tr(context, 'Audio only', 'فقط صدا (در پیش‌زمینه)'), + ), + value: _audioOnly, + onChanged: (v) { + _guard(_setAudioOnly(v)); + update(() {}); + }, + ), + ListTile( + leading: const Icon(Icons.screen_rotation), + title: Text(tr(context, 'Rotate player', 'چرخاندن صفحه')), + onTap: () { + Navigator.pop(sheetContext); + _guard( + SystemChrome.setPreferredOrientations( + MediaQuery.orientationOf(context) == + Orientation.portrait + ? [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ] + : [DeviceOrientation.portraitUp], + ), + ); + }, + ), + if (MediaPlatform.supported) ...[ + ListTile( + leading: const Icon(Icons.brightness_6_outlined), + title: Text(tr(context, 'Brightness', 'روشنایی')), + subtitle: Slider( + value: _brightness, + onChanged: (v) { + update(() => _brightness = v); + _guard( + MediaPlatform.invoke('setBrightness', { + 'value': v, + }), + ); + }, + ), + ), + ListTile( + leading: const Icon(Icons.volume_up_outlined), + title: Text(tr(context, 'System volume', 'صدای دستگاه')), + subtitle: Slider( + value: _volume, + onChanged: (v) { + update(() => _volume = v); + _guard(_setVolume(v)); + }, + ), + ), + ListTile( + leading: const Icon(Icons.open_in_new), + title: Text( + tr(context, 'Open external player', 'پخش‌کننده خارجی'), + ), + onTap: () { + Navigator.pop(sheetContext); + _guard(_externalPlayer()); + }, + ), + ], + ], + ), + ), + ); + }, + ), + ); + _settingsOpen = false; + _scheduleHide(); + } + + double _brightness = .6, _volume = .5; + Future _remoteSubtitles() async { + final list = await _app!.api.subtitles( + _episode == null ? 'movie' : 'episode', + _episode?.id ?? widget.title.id, + ); + if (!mounted) return; + if (list.isEmpty) { + _notice( + tr( + context, + 'No subtitles returned by the service', + 'سرویس زیرنویسی برنگرداند', + ), + ); + return; + } + final groups = subtitleGroups(list); + await showModalBottomSheet( + context: context, + builder: (sheetContext) => ListView( + children: [ + for (final group in groups.entries) + ExpansionTile( + title: Text(group.key), + initiallyExpanded: groups.length == 1, + children: [ + for (final variant in group.value) + ListTile( + title: Text(variant.label), + onTap: () { + Navigator.pop(sheetContext); + _guard(_loadRemoteSubtitle(variant)); + }, + ), + ], + ), + ], + ), + ); + } + + Future _loadRemoteSubtitle(SubtitleVariant variant) async { + final episode = _episode?.id; + final source = _source.url; + final client = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 20), + ), + ); + final cancel = CancelToken(); + try { + final response = await client.get>( + variant.url, + options: Options(responseType: ResponseType.bytes), + cancelToken: cancel, + onReceiveProgress: (count, total) { + if (count > 8 * 1024 * 1024) cancel.cancel('Subtitle exceeds 8 MB'); + }, + ); + if (!mounted || + !_lifecycle.ownsSystem || + episode != _episode?.id || + source != _source.url) { + return; + } + final bytes = response.data ?? const []; + if (bytes.length > 8 * 1024 * 1024) { + throw StateError('Subtitle exceeds 8 MB'); + } + final text = decodeSubtitle(bytes); + await _selectSubtitleTrack( + SubtitleTrack.data( + text, + title: variant.label, + language: variant.language, + ), + sourceUrl: variant.url, + ); + } finally { + client.close(force: true); + } + } +} + +class PlayerSurfaceGestures extends StatelessWidget { + const PlayerSurfaceGestures({ + super.key, + required this.locked, + required this.onAction, + }); + final bool locked; + final void Function(String) onAction; + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: locked ? null : () => onAction('controls'), + onDoubleTap: locked ? null : () => onAction('fullscreen'), + onScaleUpdate: locked + ? null + : (details) { + if (details.pointerCount >= 2 && details.scale > 1.12) { + onAction('fill'); + } + if (details.pointerCount >= 2 && details.scale < .88) { + onAction('contain'); + } + }, + child: const SizedBox.expand(), + ); +} + +/// Keyboard and hover handling shared by the player and its controls. +class PlayerInteraction extends StatelessWidget { + const PlayerInteraction({ + super.key, + required this.locked, + required this.controlsVisible, + required this.onAction, + required this.child, + }); + final bool locked, controlsVisible; + final void Function(String) onAction; + final Widget child; + @override + Widget build(BuildContext context) { + void action(String value) { + if (!locked) onAction(value); + } + + return CallbackShortcuts( + bindings: { + for (final entry in { + LogicalKeyboardKey.space: 'toggle', + LogicalKeyboardKey.keyK: 'toggle', + LogicalKeyboardKey.mediaPlayPause: 'toggle', + LogicalKeyboardKey.mediaPlay: 'play', + LogicalKeyboardKey.mediaPause: 'pause', + LogicalKeyboardKey.mediaStop: 'stop', + LogicalKeyboardKey.keyM: 'mute', + LogicalKeyboardKey.keyF: 'fullscreen', + LogicalKeyboardKey.escape: 'exitFullscreen', + LogicalKeyboardKey.arrowLeft: 'back', + LogicalKeyboardKey.arrowRight: 'forward', + LogicalKeyboardKey.keyJ: 'back', + LogicalKeyboardKey.keyL: 'forward', + LogicalKeyboardKey.arrowUp: 'volumeUp', + LogicalKeyboardKey.arrowDown: 'volumeDown', + LogicalKeyboardKey.mediaTrackNext: 'next', + LogicalKeyboardKey.mediaTrackPrevious: 'previous', + LogicalKeyboardKey.keyN: 'next', + LogicalKeyboardKey.keyP: 'previous', + LogicalKeyboardKey.keyS: 'subtitles', + }.entries) + SingleActivator(entry.key): () => action(entry.value), + }, + child: Focus( + autofocus: true, + child: MouseRegion( + cursor: !controlsVisible && !locked + ? SystemMouseCursors.none + : MouseCursor.defer, + onHover: (_) => action('show'), + child: child, + ), + ), + ); + } +} diff --git a/lib/media/segmented_download.dart b/lib/media/segmented_download.dart new file mode 100644 index 0000000..813cf91 --- /dev/null +++ b/lib/media/segmented_download.dart @@ -0,0 +1,1075 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:archive/archive_io.dart'; + +import '../data/content_policy.dart'; + +import 'package:xml/xml.dart'; + +/// An owned resource, never a remote reference in a published manifest. +class OfflineResource { + OfflineResource({ + required this.url, + required this.filename, + this.offset, + this.length, + this.key = false, + this.bytes = 0, + this.digest = '', + }); + final String url, filename; + final int? offset, length; + final bool key; + int bytes; + String digest; + factory OfflineResource.fromJson(Map j) => OfflineResource( + url: j['url'] as String, + filename: j['filename'] as String, + offset: j['offset'] as int?, + length: j['length'] as int?, + key: j['key'] == true, + bytes: j['bytes'] as int? ?? 0, + digest: j['digest'] as String? ?? '', + ); + Map toJson() => { + 'url': url, + 'filename': filename, + 'offset': offset, + 'length': length, + 'key': key, + 'bytes': bytes, + 'digest': digest, + }; +} + +class OfflinePackage { + OfflinePackage({ + required this.id, + required this.url, + required this.directory, + required this.manifest, + required this.resources, + required this.manifests, + this.metadata = const {}, + this.status = 'paused', + this.error = '', + }); + final String id, url, directory, manifest; + final List resources; + final Map manifests; + final Map metadata; + String status, error; + int get receivedBytes => resources.fold(0, (n, r) => n + r.bytes); + double get progress => resources.isEmpty + ? 0 + : resources.where((r) => r.digest.isNotEmpty).length / resources.length; + String get path => '$directory/$manifest'; + factory OfflinePackage.fromJson(Map j, String directory) => + OfflinePackage( + id: j['id'] as String, + url: j['url'] as String, + directory: directory, + manifest: j['manifest'] as String, + resources: (j['resources'] as List) + .map( + (r) => + OfflineResource.fromJson(Map.from(r as Map)), + ) + .toList(), + manifests: Map.from(j['manifests'] as Map), + metadata: Map.from(j['metadata'] as Map? ?? {}), + status: j['status'] as String, + error: j['error'] as String? ?? '', + ); + Map toJson() => { + 'version': 1, + 'id': id, + 'url': url, + 'manifest': manifest, + 'resources': resources.map((r) => r.toJson()).toList(), + 'manifests': manifests, + 'metadata': metadata, + 'status': status, + 'error': error, + }; +} + +class _Stopped implements Exception {} + +/// Durable, bounded package orchestration. Runs only while Dart is alive: +/// interrupted work recovers paused, never as a false completed download. +/// Each completed resource has a length AND SHA-256 check before reuse. +class SegmentedDownload { + SegmentedDownload({ + required this.root, + int parallel = 3, + this.transfer, + this.cancelTransfers, + }) : parallel = parallel.clamp(1, 8); + final Directory root; + int parallel; + final Future Function(OfflinePackage, OfflineResource, String)? + transfer; + final Future Function(String)? cancelTransfers; + final Map _running = {}; + final Map _stopped = {}; + final Map> _writes = {}; + static bool _safeName(String s) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(s); + Directory _directory(String id) { + if (!_safeName(id)) throw ArgumentError('Invalid package id'); + return Directory('${root.path}/$id'); + } + + static Uri _remote(String value) { + final uri = Uri.parse(value); + if (!['http', 'https'].contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + !ContentPolicy.allowsUrl(value)) { + throw UnsupportedError( + 'Only authorized public HTTP(S) media is supported.', + ); + } + return uri; + } + + Future _save(OfflinePackage p) { + final previous = _writes[p.id] ?? Future.value(); + final next = previous.catchError((Object _) {}).then((_) async { + final file = File('${p.directory}/package.json'); + final tmp = File('${file.path}.tmp'); + await tmp.writeAsString(jsonEncode(p.toJson()), flush: true); + await tmp.rename(file.path); + }); + _writes[p.id] = next; + return next; + } + + Future load(String id) async { + final dir = _directory(id); + final p = OfflinePackage.fromJson( + jsonDecode(await File('${dir.path}/package.json').readAsString()) + as Map, + dir.path, + ); + if (p.id != id || + p.resources.any((r) => !_local(r.filename)) || + p.manifests.keys.any((s) => !_local(s)) || + !p.manifests.containsKey(p.manifest)) { + throw const FormatException('Invalid offline package index.'); + } + return p; + } + + static bool _local(String name) => + RegExp(r'^[a-zA-Z0-9_.-]+$').hasMatch(name) && !name.startsWith('.'); + Future> recover() async { + if (!await root.exists()) return []; + final result = []; + await for (final dir in root.list(followLinks: false)) { + if (dir is! Directory || + !_safeName(dir.uri.pathSegments.where((s) => s.isNotEmpty).last)) { + continue; + } + try { + final p = await load( + dir.uri.pathSegments.where((s) => s.isNotEmpty).last, + ); + if (_running.containsKey(p.id)) { + result.add(p); + continue; + } + if (p.status == 'running' || p.status == 'enqueued') { + p.status = 'paused'; + await _save(p); + } + if (p.status == 'complete' && !await validate(p.id)) { + p.status = 'failed'; + p.error = 'Offline files are missing or damaged; retry to repair.'; + await _save(p); + } + result.add(p); + } on FileSystemException { + /* Unfinished planning is not a published package. */ + } on FormatException { + /* Corrupt indices never become playable entries. */ + } + } + return result; + } + + /// Own redirects so every request (including resources) passes the same policy. + /// Return the effective URI: relative manifest references belong to its origin. + Future<(Uri, HttpClientResponse)> _get( + HttpClient client, + Uri uri, { + Map headers = const {}, + }) async { + for (var redirects = 0; ; redirects++) { + final request = await client.getUrl(_remote(uri.toString())); + request.followRedirects = false; + headers.forEach(request.headers.set); + final response = await request.close().timeout( + const Duration(seconds: 30), + ); + if (!const [301, 302, 303, 307, 308].contains(response.statusCode)) { + return (uri, response); + } + final location = response.headers.value(HttpHeaders.locationHeader); + // Cancel rather than consuming an unbounded redirect response body. + await response.listen((_) {}).cancel(); + if (redirects >= 5) { + throw const HttpException('Too many media redirects.'); + } + if (location == null || location.trim().isEmpty) { + throw const HttpException('Media redirect has no Location.'); + } + uri = _remote(uri.resolve(location).toString()); + } + } + + Future<(Uri, String)> _text(HttpClient client, Uri uri) async { + final (effectiveUri, response) = await _get(client, uri); + if (response.statusCode != 200) { + throw HttpException('Manifest HTTP ${response.statusCode}'); + } + final bytes = []; + await for (final chunk in response.timeout(const Duration(seconds: 30))) { + bytes.addAll(chunk); + if (bytes.length > 4 * 1024 * 1024) { + throw const FormatException('Manifest is too large.'); + } + } + return (effectiveUri, utf8.decode(bytes)); + } + + Future prepare({ + required String id, + required String url, + String type = '', + Map metadata = const {}, + }) async { + final uri = _remote(url); + if (RegExp( + r'drm|widevine|playready|fairplay|protected', + caseSensitive: false, + ).hasMatch(type)) { + throw UnsupportedError('DRM/protected streams cannot be downloaded.'); + } + final directory = _directory(id); + if (await directory.exists()) throw StateError('Package already exists.'); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 30); + final resources = []; + final manifests = {}; + try { + final (effectiveUri, text) = await _text(client, uri); + final seen = {}; + String resource(Uri url, {int? offset, int? length, bool key = false}) { + _remote(url.toString()); + final identity = '$url:$offset:$length'; + final prior = resources.where( + (r) => '${r.url}:${r.offset}:${r.length}' == identity, + ); + if (prior.isNotEmpty) return prior.first.filename; + if (resources.length >= 20000) { + throw UnsupportedError('Too many offline resources.'); + } + final extension = key + ? 'key' + : (RegExp(r'\.(ts|m4s|mp4|aac|vtt|webvtt)$') + .firstMatch(url.path) + ?.group(1) ?? + 'bin'); + final name = 'resource_${resources.length}.$extension'; + resources.add( + OfflineResource( + url: url.toString(), + filename: name, + offset: offset, + length: length, + key: key, + ), + ); + return name; + } + + Future playlist(Uri url, String content, int depth) async { + if (depth > 4) { + throw UnsupportedError('HLS playlist nesting is too deep.'); + } + if (seen.containsKey(url.toString())) return seen[url.toString()]!; + final name = 'playlist_${seen.length}.m3u8'; + seen[url.toString()] = name; + final lines = const LineSplitter() + .convert(content) + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + if (lines.isEmpty || lines.first != '#EXTM3U') { + throw const FormatException('Invalid HLS playlist.'); + } + final output = ['#EXTM3U']; + if (lines.any((l) => l.startsWith('#EXT-X-STREAM-INF:'))) { + final variants = <(String, String, int)>[]; + for (var i = 0; i < lines.length; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF:')) { + if (i + 1 >= lines.length || lines[i + 1].startsWith('#')) { + throw const FormatException('Missing variant URI.'); + } + final attrs = _attributes(lines[i]); + variants.add(( + lines[i], + lines[i + 1], + int.tryParse(attrs['BANDWIDTH'] ?? '') ?? 0, + )); + } + if (lines[i].startsWith('#EXT-X-SESSION-KEY:')) { + throw UnsupportedError('HLS session keys are unsupported.'); + } + } + variants.sort((a, b) => b.$3.compareTo(a.$3)); + final variant = variants.first; + final attrs = _attributes(variant.$1); + for (final kind in ['AUDIO', 'SUBTITLES']) { + final group = attrs[kind]; + if (group == null) continue; + final choices = lines + .where((l) => l.startsWith('#EXT-X-MEDIA:')) + .where((l) { + final a = _attributes(l); + return a['TYPE'] == kind && a['GROUP-ID'] == group; + }) + .toList(); + if (choices.isEmpty) { + throw UnsupportedError('Missing HLS $kind rendition.'); + } + final selected = choices.firstWhere( + (l) => _attributes(l)['DEFAULT'] == 'YES', + orElse: () => choices.first, + ); + final a = _attributes(selected); + if (a['URI'] != null) { + final child = url.resolve(a['URI']!); + final (effectiveChild, childText) = await _text(client, child); + final local = await playlist( + effectiveChild, + childText, + depth + 1, + ); + output.add(_replaceAttribute(selected, 'URI', local)); + } else if (kind == 'AUDIO') { + output.add(selected); + } else { + throw UnsupportedError('Missing subtitle rendition URI.'); + } + } + final child = url.resolve(variant.$2); + final (effectiveChild, childText) = await _text(client, child); + final local = await playlist(effectiveChild, childText, depth + 1); + output.addAll([variant.$1, local]); + } else { + if (!lines.contains('#EXT-X-ENDLIST')) { + throw UnsupportedError( + 'Live/unbounded HLS is not an offline download.', + ); + } + int? nextLength, nextOffset; + var previousEnd = 0; + String? previousUrl; + var segments = 0; + var encrypted = false, explicitIv = false; + for (final line in lines.skip(1)) { + if (line.startsWith('#EXT-X-BYTERANGE:')) { + final parts = line.split(':').last.split('@'); + nextLength = int.tryParse(parts[0]); + nextOffset = parts.length > 1 ? int.tryParse(parts[1]) : null; + if (nextLength == null || nextLength <= 0) { + throw const FormatException('Invalid HLS byte range.'); + } + } else if (line.startsWith('#EXT-X-KEY:') || + line.startsWith('#EXT-X-MAP:')) { + final a = _attributes(line); + final key = line.startsWith('#EXT-X-KEY:'); + if (key && a['METHOD'] == 'NONE') { + encrypted = false; + explicitIv = false; + output.add(line); + continue; + } + if (!key && encrypted && !explicitIv) { + throw UnsupportedError( + 'Encrypted HLS init maps require an explicit IV.', + ); + } + if (key) { + encrypted = true; + explicitIv = a['IV'] != null; + if (explicitIv && + !RegExp(r'^0[xX][0-9a-fA-F]{32}$').hasMatch(a['IV']!)) { + throw UnsupportedError('Invalid AES-128 IV.'); + } + } + if (key && + (a['METHOD'] != 'AES-128' || + (a['KEYFORMAT'] != null && + a['KEYFORMAT'] != 'identity'))) { + throw UnsupportedError( + 'DRM/protected HLS encryption is unsupported.', + ); + } + final ref = a['URI']; + if (ref == null) { + throw const FormatException('Missing HLS resource URI.'); + } + int? length, offset; + if (a['BYTERANGE'] != null) { + final parts = a['BYTERANGE']!.split('@'); + length = int.tryParse(parts[0]); + offset = parts.length == 2 ? int.tryParse(parts[1]) : null; + if (length == null || + length <= 0 || + offset == null || + offset < 0) { + throw UnsupportedError( + 'Map byte range requires an explicit valid offset.', + ); + } + } + final local = resource( + url.resolve(ref), + offset: offset, + length: length, + key: key, + ); + output.add( + _replaceAttribute( + line, + 'URI', + local, + ).replaceAll(RegExp(r',?BYTERANGE="[^"]*"'), ''), + ); + } else if (!line.startsWith('#')) { + final resolved = url.resolve(line); + final offset = nextLength == null + ? null + : nextOffset ?? + (previousUrl == resolved.toString() + ? previousEnd + : throw const FormatException( + 'Implicit range has no preceding resource.', + )); + output.add( + resource(resolved, offset: offset, length: nextLength), + ); + if (nextLength != null) previousEnd = offset! + nextLength; + previousUrl = resolved.toString(); + nextLength = null; + nextOffset = null; + segments++; + } else { + if (line.contains('URI=') || + line.startsWith('#EXT-X-PART') || + line.startsWith('#EXT-X-SKIP') || + line.startsWith('#EXT-X-SESSION')) { + throw UnsupportedError('Unsupported HLS resource extension.'); + } + output.add(line); + } + } + if (segments == 0 || nextLength != null) { + throw const FormatException('Empty or incomplete media playlist.'); + } + } + manifests[name] = '${output.join('\n')}\n'; + return name; + } + + final String manifest; + if (text.trimLeft().startsWith('#EXTM3U')) { + manifest = await playlist(effectiveUri, text, 0); + } else { + manifest = 'offline.mpd'; + manifests[manifest] = _dash( + text, + effectiveUri, + (url, offset, length) => + resource(url, offset: offset, length: length), + ); + } + if (resources.isEmpty) { + throw const FormatException('Empty offline package.'); + } + await directory.create(recursive: true); + final p = OfflinePackage( + id: id, + url: url, + directory: directory.path, + manifest: manifest, + resources: resources, + manifests: manifests, + metadata: metadata, + ); + await _save(p); + return p; + } finally { + client.close(force: true); + } + } + + static String _dash( + String text, + Uri origin, + String Function(Uri, int?, int?) resource, + ) { + if (text.contains(' + a.name.local == 'href' || + a.name.local == 'availabilityTimeOffset', + )) { + throw UnsupportedError('DRM/protected or unsupported DASH addressing.'); + } + } + List children(XmlElement e, String tag) => + e.childElements.where((x) => x.name.local == tag).toList(); + XmlElement? one(XmlElement e, String tag) { + final found = children(e, tag); + if (found.length > 1) { + throw UnsupportedError('Multiple DASH $tag entries are unsupported.'); + } + return found.isEmpty ? null : found.single; + } + + Uri base(XmlElement element, Uri parent) { + final node = one(element, 'BaseURL'); + return node == null + ? parent + : _remote(parent.resolve(node.innerText.trim()).toString()); + } + + double? seconds(String? value) { + if (value == null) return null; + final m = RegExp( + r'^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$', + ).firstMatch(value); + if (m == null) throw UnsupportedError('Unsupported DASH duration.'); + return (double.tryParse(m[1] ?? '') ?? 0) * 86400 + + (double.tryParse(m[2] ?? '') ?? 0) * 3600 + + (double.tryParse(m[3] ?? '') ?? 0) * 60 + + (double.tryParse(m[4] ?? '') ?? 0); + } + + final periods = children(mpd, 'Period'); + if (periods.length != 1) { + throw UnsupportedError('Exactly one finite DASH Period is supported.'); + } + final period = periods.single; + if ((seconds(period.getAttribute('start')) ?? 0) != 0) { + throw UnsupportedError('Nonzero DASH Period start is unsupported.'); + } + final duration = + seconds(period.getAttribute('duration')) ?? + seconds(mpd.getAttribute('mediaPresentationDuration')); + final periodBase = base(period, base(mpd, origin)); + final adaptations = children(period, 'AdaptationSet'); + if (adaptations.isEmpty) { + throw const FormatException('DASH has no adaptations.'); + } + for (final adaptation in adaptations) { + final representations = children(adaptation, 'Representation'); + if (representations.isEmpty) { + throw UnsupportedError('DASH representation is missing.'); + } + representations.sort( + (a, b) => (int.tryParse(b.getAttribute('bandwidth') ?? '') ?? 0) + .compareTo(int.tryParse(a.getAttribute('bandwidth') ?? '') ?? 0), + ); + final rep = representations.first; + final repBase = base(rep, base(adaptation, periodBase)); + final chain = [mpd, period, adaptation, rep]; + final templateAttrs = {}; + XmlElement? template, list, timeline; + for (final scope in chain) { + final t = one(scope, 'SegmentTemplate'), l = one(scope, 'SegmentList'); + if (t != null) { + template = t; + for (final a in t.attributes) { + templateAttrs[a.name.local] = a.value; + } + timeline = one(t, 'SegmentTimeline') ?? timeline; + } + if (l != null) list = l; + } + if ((template == null) == (list == null)) { + throw UnsupportedError( + 'DASH requires one supported SegmentTemplate or SegmentList.', + ); + } + final output = XmlElement(XmlName.parts('SegmentList')); + int? integer(String? v) => v == null ? null : int.tryParse(v); + int positive(String? v, int fallback) { + final n = integer(v) ?? fallback; + if (n <= 0) { + throw const FormatException('Invalid DASH timescale/duration.'); + } + return n; + } + + String local(String ref, String? byteRange) { + int? offset, length; + if (byteRange != null) { + final m = RegExp(r'^(\d+)-(\d+)$').firstMatch(byteRange); + if (m == null) { + throw const FormatException('Invalid DASH byte range.'); + } + offset = int.parse(m[1]!); + length = int.parse(m[2]!) - offset + 1; + if (length <= 0) { + throw const FormatException('Invalid DASH byte range.'); + } + } + return resource(repBase.resolve(ref), offset, length); + } + + void initialization(String ref, String? range) { + output.children.add( + XmlElement(XmlName.parts('Initialization'), [ + XmlAttribute(XmlName.parts('sourceURL'), local(ref, range)), + ]), + ); + } + + void segment(String ref, String? range) { + output.children.add( + XmlElement(XmlName.parts('SegmentURL'), [ + XmlAttribute(XmlName.parts('media'), local(ref, range)), + ]), + ); + } + + if (list != null) { + for (final a in list.attributes) { + if (![ + 'timescale', + 'duration', + 'startNumber', + 'presentationTimeOffset', + ].contains(a.name.local)) { + throw UnsupportedError('Unsupported DASH SegmentList attribute.'); + } + output.attributes.add(a.copy()); + } + final init = one(list, 'Initialization'); + if (init != null) { + final ref = init.getAttribute('sourceURL'); + if (ref == null) { + throw UnsupportedError( + 'DASH initialization without sourceURL is unsupported.', + ); + } + initialization(ref, init.getAttribute('range')); + } + final t = one(list, 'SegmentTimeline'); + if (t != null) output.children.add(t.copy()); + final segments = children(list, 'SegmentURL'); + if (segments.isEmpty) { + throw const FormatException('DASH segment list is empty.'); + } + for (final s in segments) { + final ref = s.getAttribute('media'); + if (ref == null || + s.getAttribute('index') != null || + s.getAttribute('indexRange') != null) { + throw UnsupportedError('Unsupported DASH segment indexing.'); + } + segment(ref, s.getAttribute('mediaRange')); + } + if (t == null && list.getAttribute('duration') == null) { + throw UnsupportedError('DASH list requires explicit timing.'); + } + } else { + if (templateAttrs.keys.any( + (k) => ![ + 'timescale', + 'duration', + 'startNumber', + 'presentationTimeOffset', + 'initialization', + 'media', + ].contains(k), + )) { + throw UnsupportedError('Unsupported DASH template attributes.'); + } + final timescale = positive(templateAttrs['timescale'], 1); + final start = integer(templateAttrs['startNumber']) ?? 1; + final pto = integer(templateAttrs['presentationTimeOffset']) ?? 0; + if (start < 0 || pto < 0) { + throw const FormatException('Invalid DASH template timing.'); + } + output.setAttribute('timescale', '$timescale'); + output.setAttribute('startNumber', '$start'); + if (pto != 0) output.setAttribute('presentationTimeOffset', '$pto'); + String expand(String pattern, int number, int time) { + const escaped = '__DASH_DOLLAR__'; + var value = pattern.replaceAll('\$\$', escaped); + value = value.replaceAllMapped( + RegExp( + r'\$(RepresentationID|Bandwidth|Number|Time)(?:%0(\d+)d)?\$', + ), + (m) { + final token = m[1]!; + final replacement = switch (token) { + 'RepresentationID' => rep.getAttribute('id'), + 'Bandwidth' => rep.getAttribute('bandwidth'), + 'Number' => '$number', + _ => '$time', + }; + if (replacement == null) { + throw UnsupportedError('Missing DASH $token.'); + } + final width = int.tryParse(m[2] ?? '') ?? 0; + if (width > 20) { + throw UnsupportedError('DASH number formatting too wide.'); + } + return replacement.padLeft(width, '0'); + }, + ); + if (value.contains('\$')) { + throw UnsupportedError('Unsupported DASH template token.'); + } + return value.replaceAll(escaped, '\$'); + } + + final init = templateAttrs['initialization']; + if (init != null) initialization(expand(init, start, 0), null); + final media = templateAttrs['media']; + if (media == null) { + throw const FormatException('DASH media template is missing.'); + } + if (timeline != null) { + var time = 0, number = start; + final entries = children(timeline, 'S'); + if (entries.isEmpty) { + throw const FormatException('Empty DASH timeline.'); + } + for (final s in entries) { + time = integer(s.getAttribute('t')) ?? time; + final d = positive(s.getAttribute('d'), 0), + r = integer(s.getAttribute('r')) ?? 0; + if (time < 0 || r < 0 || r > 20000) { + throw UnsupportedError( + 'Unbounded/negative DASH timeline repeat is unsupported.', + ); + } + for (var i = 0; i <= r; i++) { + segment(expand(media, number++, time), null); + time += d; + } + } + output.children.add(timeline.copy()); + } else { + final d = positive(templateAttrs['duration'], 0); + if (duration == null || duration <= 0) { + throw UnsupportedError( + 'DASH duration is required for finite templates.', + ); + } + final count = (duration * timescale / d).ceil(); + if (count > 20000) throw UnsupportedError('Too many DASH segments.'); + output.setAttribute('duration', '$d'); + for (var i = 0; i < count; i++) { + segment(expand(media, start + i, pto + i * d), null); + } + } + } + // Keep representation codec metadata, but replace all remote addressing. + for (final scope in [adaptation, rep]) { + scope.children.removeWhere( + (n) => + n is XmlElement && + [ + 'SegmentTemplate', + 'SegmentList', + 'BaseURL', + ].contains(n.name.local), + ); + } + for (final other in representations.skip(1)) { + adaptation.children.remove(other); + } + rep.children.add(output); + } + for (final scope in [mpd, period]) { + scope.children.removeWhere( + (n) => + n is XmlElement && + [ + 'SegmentTemplate', + 'SegmentList', + 'BaseURL', + ].contains(n.name.local), + ); + } + // Never retain a fetch-capable extension/reference that we didn't package. + for (final element in [mpd, ...mpd.descendantElements]) { + if (element.name.local == 'BaseURL' || + element.attributes.any( + (a) => + ['href', 'sourceURL', 'media'].contains(a.name.local) && + (a.value.contains('://') || a.value.startsWith('/')), + )) { + throw UnsupportedError('Unpackaged DASH reference.'); + } + } + return document.toXmlString(); + } + + static Map _attributes(String line) { + final attrs = {}; + for (final match in RegExp( + r'([A-Z0-9-]+)=("[^"]*"|[^,]*)', + ).allMatches(line.substring(line.indexOf(':') + 1))) { + final value = match[2]!; + attrs[match[1]!] = value.startsWith('"') + ? value.substring(1, value.length - 1) + : value; + } + return attrs; + } + + static String _replaceAttribute(String line, String key, String value) => + line.replaceFirst(RegExp('$key=("[^"]*"|[^,]*)'), '$key="$value"'); + Future _validResource(OfflinePackage p, OfflineResource r) async { + if (r.bytes <= 0 || r.digest.isEmpty) return false; + final f = File('${p.directory}/${r.filename}'); + if (!await f.exists() || await f.length() != r.bytes) return false; + return (await sha256.bind(f.openRead()).first).toString() == r.digest; + } + + Future validate(String id) async { + final p = await load(id); + if (p.status != 'complete' || p.resources.isEmpty) return false; + for (final r in p.resources) { + if (!await _validResource(p, r)) return false; + } + for (final entry in p.manifests.entries) { + final f = File('${p.directory}/${entry.key}'); + if (!await f.exists() || await f.readAsString() != entry.value) { + return false; + } + } + return true; + } + + Future run( + String id, { + void Function(OfflinePackage)? onProgress, + }) async { + if (_running.containsKey(id)) { + throw StateError('Package is already running.'); + } + final p = await load(id); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 30); + _running[id] = client; + _stopped.remove(id); + p.status = 'running'; + p.error = ''; + await _save(p); + onProgress?.call(p); + Object? failure; + var cursor = 0; + Future worker() async { + while (cursor < p.resources.length && + failure == null && + !_stopped.containsKey(id)) { + final r = p.resources[cursor++]; + try { + if (await _validResource(p, r)) { + onProgress?.call(p); + continue; + } + r.bytes = 0; + r.digest = ''; + final tmp = File('${p.directory}/${r.filename}.part'); + if (transfer != null) { + await transfer!(p, r, tmp.path); + } else { + final (_, response) = await _get( + client, + _remote(r.url), + headers: { + HttpHeaders.acceptEncodingHeader: 'identity', + if (r.offset != null) + HttpHeaders.rangeHeader: + 'bytes=${r.offset}-${r.offset! + r.length! - 1}', + }, + ); + if (r.offset == null && response.statusCode != 200 || + r.offset != null && response.statusCode != 206) { + throw HttpException( + 'Resource HTTP ${response.statusCode}; range authorization is required.', + ); + } + if (r.offset != null) { + final range = response.headers.value('content-range') ?? ''; + if (!RegExp( + '^bytes ${r.offset}-${r.offset! + r.length! - 1}/[0-9]+\$', + ).hasMatch(range)) { + throw const FormatException('Incorrect Content-Range.'); + } + } + final sink = tmp.openWrite(); + var count = 0; + try { + await for (final chunk in response.timeout( + const Duration(seconds: 30), + )) { + if (_stopped.containsKey(id)) throw _Stopped(); + count += chunk.length; + if (r.length != null && count > r.length!) { + throw const FormatException('Oversized range.'); + } + sink.add(chunk); + } + } finally { + await sink.close(); + } + if (count == 0 || + r.length != null && count != r.length || + response.contentLength >= 0 && + response.contentLength != count || + r.key && count != 16) { + throw const FormatException( + 'Truncated/invalid offline resource.', + ); + } + } + final count = await tmp.length(); + if (count == 0 || + r.length != null && count != r.length || + r.key && count != 16) { + throw const FormatException('Invalid native resource length.'); + } + if (_stopped.containsKey(id)) throw _Stopped(); + final digest = (await sha256.bind(tmp.openRead()).first).toString(); + await tmp.rename('${p.directory}/${r.filename}'); + r.bytes = count; + r.digest = digest; + await _save(p); + onProgress?.call(p); + } catch (e) { + if (!_stopped.containsKey(id)) failure ??= e; + } + } + } + + try { + await Future.wait(List.generate(parallel, (_) => worker())); + if (_stopped.containsKey(id)) { + p.status = _stopped[id]!; + } else if (failure != null) { + p.status = 'failed'; + p.error = 'Offline resource failed: $failure'; + } else { + for (final r in p.resources) { + if (!await _validResource(p, r)) { + throw StateError('Resource integrity check failed.'); + } + } + for (final entry in p.manifests.entries) { + await File('${p.directory}/${entry.key}') + .writeAsString(entry.value, flush: true); + } + p.status = 'complete'; + } + } catch (e) { + p.status = 'failed'; + p.error = e.toString(); + } finally { + client.close(force: true); + _running.remove(id); + await _save(p); + onProgress?.call(p); + } + return p; + } + + Future stop(String id, {bool cancel = false}) async { + _stopped[id] = cancel ? 'canceled' : 'paused'; + final client = _running[id]; + client?.close(force: true); + if (cancelTransfers != null) await cancelTransfers!(id); + if (client == null) { + final p = await load(id); + p.status = _stopped[id]!; + await _save(p); + } else { + while (_running.containsKey(id)) { + await Future.delayed(const Duration(milliseconds: 10)); + } + await _writes[id]; + } + } + + final _exporting = {}; + Future export(String id) async { + if (!_exporting.add(id)) { + throw StateError('This package is already being exported.'); + } + try { + if (!await validate(id)) { + throw StateError('Only a complete validated package can be exported.'); + } + final p = await load(id); + final dir = await Directory('${root.path}/exports') + .create(recursive: true); + final tmp = '${dir.path}/$id.zip.part'; + final encoder = ZipFileEncoder() + ..create(tmp, level: ZipFileEncoder.store); + try { + // Deliberately omit package.json: it contains original remote URL metadata. + for (final name in { + ...p.manifests.keys, + ...p.resources.map((r) => r.filename), + }) { + await encoder.addFile(File('${p.directory}/$name'), name); + } + } finally { + await encoder.close(); + } + final path = '${dir.path}/$id.zip'; + await File(tmp).rename(path); + return path; + } finally { + _exporting.remove(id); + } + } + + Future delete(String id) async { + if (_running.containsKey(id)) await stop(id, cancel: true); + await _directory(id).delete(recursive: true); + } +} diff --git a/lib/media/subtitle_webvtt.dart b/lib/media/subtitle_webvtt.dart new file mode 100644 index 0000000..d593fa8 --- /dev/null +++ b/lib/media/subtitle_webvtt.dart @@ -0,0 +1,174 @@ +/// Converts already-decoded SRT, SSA/ASS or WebVTT text to plain-cue WebVTT. +/// Deliberately drops styling, attachments, identifiers and positioning rather +/// than allowing receiver-side fetches or pretending to preserve ASS rendering. +class SubtitleWebVtt { + static String convert(String source) { + final text = source + .replaceFirst(RegExp(r'^\uFEFF'), '') + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n') + .trim(); + final cues = <_Cue>[]; + if (RegExp(r'^\[Script Info\]', caseSensitive: false).hasMatch(text)) { + _ass(text, cues); + } else { + final blocks = text.split(RegExp(r'\n[ \t]*\n')); + final vtt = RegExp(r'^WEBVTT(?:[ \t]|$)') + .hasMatch(blocks.first.split('\n').first); + if (vtt) blocks.removeAt(0); // Header metadata is never propagated. + for (final block in blocks) { + final lines = block.trim().split('\n'); + if (vtt && + RegExp(r'^(?:STYLE|REGION|NOTE)(?:[ \t]|$)') + .hasMatch(lines.first)) { + continue; + } + if (lines.length > 1 && !lines.first.contains('-->')) { + if (!vtt && !RegExp(r'^\d+$').hasMatch(lines.first)) { + throw const FormatException( + 'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.', + ); + } + lines.removeAt(0); + } + if (lines.length < 2) { + throw const FormatException('Invalid or empty subtitle cue.'); + } + final timing = RegExp(r'^(\S+)[ \t]+-->[ \t]+(\S+)(?:[ \t]+.*)?$') + .firstMatch(lines.first); + if (timing == null) { + throw const FormatException( + 'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.', + ); + } + cues.add( + _Cue( + _time(timing[1]!, short: vtt), + _time(timing[2]!, short: vtt), + _plain(lines.skip(1).join('\n')), + ), + ); + } + } + if (cues.isEmpty) { + throw const FormatException('No supported subtitle cues.'); + } + var previous = -1; + for (final cue in cues) { + if (cue.end <= cue.start || cue.start < previous || cue.body.isEmpty) { + throw const FormatException( + 'Invalid subtitle cue timing/order or empty text.', + ); + } + previous = cue.start; + } + return 'WEBVTT\n\n${cues.map((c) => '${_stamp(c.start)} --> ${_stamp(c.end)}\n${c.body}').join('\n\n')}\n\n'; + } + + static int _time(String value, {bool short = false, bool ass = false}) { + final expression = ass + ? r'^(\d{1,6}):(\d{2}):(\d{2})\.(\d{2})$' + : r'^(\d{2,6}):(\d{2}):(\d{2})[.,](\d{3})$'; + var match = RegExp(expression).firstMatch(value); + if (match == null && short) { + match = RegExp(r'^(\d{2}):(\d{2})\.(\d{3})$').firstMatch(value); + if (match != null) { + return _time('00:$value'); + } + } + if (match == null) { + throw const FormatException('Invalid subtitle timestamp.'); + } + final h = int.parse(match[1]!); + final m = int.parse(match[2]!); + final s = int.parse(match[3]!); + if (m > 59 || s > 59) { + throw const FormatException('Invalid subtitle timestamp range.'); + } + return ((h * 60 + m) * 60 + s) * 1000 + + int.parse(match[4]!) * (ass ? 10 : 1); + } + + static String _stamp(int ms) { + String pad(int value, int width) => '$value'.padLeft(width, '0'); + return '${pad(ms ~/ 3600000, 2)}:${pad(ms ~/ 60000 % 60, 2)}:${pad(ms ~/ 1000 % 60, 2)}.${pad(ms % 1000, 3)}'; + } + + static void _ass(String text, List<_Cue> cues) { + var events = false; + List? fields; + for (final raw in text.split('\n')) { + final line = raw.trim(); + if (line.startsWith('[')) { + events = line.toLowerCase() == '[events]'; + continue; + } + if (!events) continue; + if (line.toLowerCase().startsWith('format:')) { + fields = line + .substring(line.indexOf(':') + 1) + .split(',') + .map((s) => s.trim().toLowerCase()) + .toList(); + } else if (line.toLowerCase().startsWith('dialogue:')) { + if (fields == null || + fields.last != 'text' || + !fields.contains('start') || + !fields.contains('end') || + fields.toSet().length != fields.length) { + throw const FormatException('Invalid SSA/ASS Events format.'); + } + final parts = line.substring(line.indexOf(':') + 1).split(','); + if (parts.length < fields.length) { + throw const FormatException('Incomplete SSA/ASS dialogue.'); + } + final body = parts.skip(fields.length - 1).join(','); + if (RegExp(r'\\p[1-9]').hasMatch(body)) { + throw UnsupportedError('ASS drawing subtitles are not supported.'); + } + cues.add( + _Cue( + _time(parts[fields.indexOf('start')].trim(), ass: true), + _time(parts[fields.indexOf('end')].trim(), ass: true), + _plain( + body + .replaceAll(r'\N', '\n') + .replaceAll(r'\n', '\n') + .replaceAll(r'\h', ' '), + ), + ), + ); + } + } + } + + static String _plain(String body) { + // Plain escaped text is a strict subset of WebVTT: no HTML resources, + // CSS, voice/class attributes, ASS overrides, or embedded timing tags. + return body + .replaceAll( + RegExp( + r'<(script|style)\b[^>]*>.*?', + caseSensitive: false, + dotAll: true, + ), + '', + ) + .replaceAll(RegExp(r'\{[^}]*\}'), '') + .replaceAll(RegExp(r'<[^>]*>', dotAll: true), '') + .replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .trim(); + } +} + +class _Cue { + const _Cue(this.start, this.end, this.body); + final int start, end; + final String body; +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart new file mode 100644 index 0000000..9d563aa --- /dev/null +++ b/lib/state/app_state.dart @@ -0,0 +1,1075 @@ +/// Account-free, device-local preferences and library state. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../data/api_service.dart'; +import '../data/models.dart'; +import '../data/content_policy.dart'; + +/// Movies and series share the poster endpoint; channels have their own IDs. +extension LocalTitleIdentity on TitleRecord { + String get libraryKey => '${isChannel ? 'channel' : 'poster'}:$id'; +} + +String _libraryKey(int id, String kind) => + '${kind == 'channel' ? 'channel' : 'poster'}:$id'; + +class AppSettings { + AppSettings({ + this.locale = 'en', + this.theme = 'dark', + this.wifiOnlyDownloads = false, + this.autoNext = true, + this.resumePlayback = true, + this.autoplay = false, + this.externalPlayer = false, + this.defaultQuality = 'ask', + this.playbackSpeed = 1.0, + this.skipSeconds = 10, + this.subtitleFontSize = 24.0, + this.subtitleColor = 0xFFFFFFFF, + this.subtitleBackground = 0x80000000, + this.subtitleDelay = 0.0, + this.audioDelay = 0.0, + this.orientation = 'auto', + List preferredGenres = const [], + this.hideTrailers = false, + this.downloadNotifications = true, + this.dataSaver = false, + this.subtitleEnabled = true, + this.subtitleFontFamily = 'sans-serif', + this.videoFit = 'contain', + this.audioOnly = false, + this.playerVolume = 1.0, + this.rememberVolume = false, + this.backgroundAudio = false, + this.closeOnCompletion = false, + this.resumeRewindSeconds = 5, + this.parallelDownloads = 3, + this.compactCatalog = false, + this.introCompleted = false, + this.externalPlayerPackage = 'ask', + }) : preferredGenres = List.unmodifiable(preferredGenres); + + final String locale, theme, defaultQuality, orientation; + final bool wifiOnlyDownloads, + autoNext, + resumePlayback, + autoplay, + externalPlayer, + hideTrailers, + downloadNotifications, + dataSaver; + final double playbackSpeed, subtitleFontSize, subtitleDelay, audioDelay; + final int skipSeconds, subtitleColor, subtitleBackground; + final List preferredGenres; + final bool subtitleEnabled; + final String subtitleFontFamily; + final String videoFit; + final bool audioOnly; + final double playerVolume; + final bool rememberVolume; + final bool backgroundAudio; + final bool closeOnCompletion; + final int resumeRewindSeconds; + final int parallelDownloads; + final bool compactCatalog; + final bool introCompleted; + final String externalPlayerPackage; + + /// Resolve generic preferences to platform font names rather than treating + /// CSS generic names as bundled Flutter fonts. Missing custom families still + /// fall back through these names and Flutter's system glyph fallback. + String? get resolvedSubtitleFontFamily { + final apple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + final android = defaultTargetPlatform == TargetPlatform.android; + final windows = defaultTargetPlatform == TargetPlatform.windows; + return switch (subtitleFontFamily) { + 'sans-serif' => null, // Flutter's actual platform default. + 'serif' => + apple || windows + ? 'Times New Roman' + : android + ? 'Noto Serif' + : 'DejaVu Serif', + 'monospace' => + apple + ? 'Menlo' + : windows + ? 'Consolas' + : android + ? 'Droid Sans Mono' + : 'DejaVu Sans Mono', + _ => subtitleFontFamily, + }; + } + + List get subtitleFontFamilyFallback => + switch (defaultTargetPlatform) { + TargetPlatform.android => const [ + 'Roboto', + 'Noto Sans Arabic', + 'Noto Naskh Arabic', + ], + TargetPlatform.iOS || + TargetPlatform.macOS => const ['Helvetica Neue', 'Arial', 'Geeza Pro'], + TargetPlatform.windows => const ['Segoe UI', 'Arial', 'Tahoma'], + _ => const ['DejaVu Sans', 'Noto Sans', 'Noto Sans Arabic'], + }; + + AppSettings copyWith({ + String? locale, + String? theme, + bool? wifiOnlyDownloads, + bool? autoNext, + bool? resumePlayback, + bool? autoplay, + bool? externalPlayer, + String? defaultQuality, + double? playbackSpeed, + int? skipSeconds, + double? subtitleFontSize, + int? subtitleColor, + int? subtitleBackground, + double? subtitleDelay, + double? audioDelay, + String? orientation, + List? preferredGenres, + bool? hideTrailers, + bool? downloadNotifications, + bool? dataSaver, + bool? subtitleEnabled, + String? subtitleFontFamily, + String? videoFit, + bool? audioOnly, + double? playerVolume, + bool? rememberVolume, + bool? backgroundAudio, + bool? closeOnCompletion, + int? resumeRewindSeconds, + int? parallelDownloads, + bool? compactCatalog, + bool? introCompleted, + String? externalPlayerPackage, + }) => AppSettings( + locale: locale ?? this.locale, + theme: theme ?? this.theme, + wifiOnlyDownloads: wifiOnlyDownloads ?? this.wifiOnlyDownloads, + autoNext: autoNext ?? this.autoNext, + resumePlayback: resumePlayback ?? this.resumePlayback, + autoplay: autoplay ?? this.autoplay, + externalPlayer: externalPlayer ?? this.externalPlayer, + defaultQuality: defaultQuality ?? this.defaultQuality, + playbackSpeed: playbackSpeed ?? this.playbackSpeed, + skipSeconds: skipSeconds ?? this.skipSeconds, + subtitleFontSize: subtitleFontSize ?? this.subtitleFontSize, + subtitleColor: subtitleColor ?? this.subtitleColor, + subtitleBackground: subtitleBackground ?? this.subtitleBackground, + subtitleDelay: subtitleDelay ?? this.subtitleDelay, + audioDelay: audioDelay ?? this.audioDelay, + orientation: orientation ?? this.orientation, + preferredGenres: preferredGenres ?? this.preferredGenres, + hideTrailers: hideTrailers ?? this.hideTrailers, + downloadNotifications: downloadNotifications ?? this.downloadNotifications, + dataSaver: dataSaver ?? this.dataSaver, + subtitleEnabled: subtitleEnabled ?? this.subtitleEnabled, + subtitleFontFamily: subtitleFontFamily ?? this.subtitleFontFamily, + videoFit: videoFit ?? this.videoFit, + audioOnly: audioOnly ?? this.audioOnly, + playerVolume: playerVolume ?? this.playerVolume, + rememberVolume: rememberVolume ?? this.rememberVolume, + backgroundAudio: backgroundAudio ?? this.backgroundAudio, + closeOnCompletion: closeOnCompletion ?? this.closeOnCompletion, + resumeRewindSeconds: resumeRewindSeconds ?? this.resumeRewindSeconds, + parallelDownloads: parallelDownloads ?? this.parallelDownloads, + compactCatalog: compactCatalog ?? this.compactCatalog, + introCompleted: introCompleted ?? this.introCompleted, + externalPlayerPackage: externalPlayerPackage ?? this.externalPlayerPackage, + ); + + Map toJson() => { + 'locale': locale, + 'theme': theme, + 'wifiOnlyDownloads': wifiOnlyDownloads, + 'autoNext': autoNext, + 'resumePlayback': resumePlayback, + 'autoplay': autoplay, + 'externalPlayer': externalPlayer, + 'defaultQuality': defaultQuality, + 'playbackSpeed': playbackSpeed, + 'skipSeconds': skipSeconds, + 'subtitleFontSize': subtitleFontSize, + 'subtitleColor': subtitleColor, + 'subtitleBackground': subtitleBackground, + 'subtitleDelay': subtitleDelay, + 'audioDelay': audioDelay, + 'orientation': orientation, + 'preferredGenres': preferredGenres, + 'hideTrailers': hideTrailers, + 'downloadNotifications': downloadNotifications, + 'dataSaver': dataSaver, + 'subtitleEnabled': subtitleEnabled, + 'subtitleFontFamily': subtitleFontFamily, + 'videoFit': videoFit, + 'audioOnly': audioOnly, + 'playerVolume': playerVolume, + 'rememberVolume': rememberVolume, + 'backgroundAudio': backgroundAudio, + 'closeOnCompletion': closeOnCompletion, + 'resumeRewindSeconds': resumeRewindSeconds, + 'parallelDownloads': parallelDownloads, + 'compactCatalog': compactCatalog, + 'introCompleted': introCompleted, + 'externalPlayerPackage': externalPlayerPackage, + }; + + factory AppSettings.fromJson(Map json) { + bool boolean(String key, bool fallback) => + json[key] is bool ? json[key] as bool : fallback; + String choice(String key, Set allowed, String fallback) => + allowed.contains(json[key]) ? json[key] as String : fallback; + double number(String key, double fallback, {double? min, double? max}) { + final value = json[key]; + if (value is! num || + !value.isFinite || + (min != null && value < min) || + (max != null && value > max)) { + return fallback; + } + return value.toDouble(); + } + + int integer(String key, int fallback, int min, int max) { + final value = json[key]; + return value is int && value >= min && value <= max ? value : fallback; + } + + String safeString(String key, String fallback, {bool package = false}) { + final value = json[key]; + if (value is! String || + value.trim().isEmpty || + value.length > 120 || + !ContentPolicy.allowsText(value) || + RegExp(r'[\x00-\x1f\x7f]').hasMatch(value) || + (package && + value != 'ask' && + !RegExp(r'^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$') + .hasMatch(value))) { + return fallback; + } + return value.trim(); + } + + final genres = json['preferredGenres']; + return AppSettings( + locale: choice('locale', {'en', 'fa'}, 'en'), + theme: choice('theme', {'dark', 'light', 'system'}, 'dark'), + wifiOnlyDownloads: boolean('wifiOnlyDownloads', false), + autoNext: boolean('autoNext', true), + resumePlayback: boolean('resumePlayback', true), + autoplay: boolean('autoplay', false), + externalPlayer: boolean('externalPlayer', false), + defaultQuality: safeString('defaultQuality', 'ask'), + playbackSpeed: number('playbackSpeed', 1, min: 0.25, max: 4), + skipSeconds: integer('skipSeconds', 10, 1, 600), + subtitleFontSize: number('subtitleFontSize', 24, min: 8, max: 96), + subtitleColor: integer('subtitleColor', 0xFFFFFFFF, 0, 0xFFFFFFFF), + subtitleBackground: integer( + 'subtitleBackground', + 0x80000000, + 0, + 0xFFFFFFFF, + ), + subtitleDelay: number('subtitleDelay', 0, min: -60, max: 60), + audioDelay: number('audioDelay', 0, min: -60, max: 60), + orientation: choice('orientation', { + 'auto', + 'portrait', + 'landscape', + }, 'auto'), + preferredGenres: genres is List + ? genres.whereType().where((id) => id > 0).toSet().toList() + : const [], + hideTrailers: boolean('hideTrailers', false), + downloadNotifications: boolean('downloadNotifications', true), + dataSaver: boolean('dataSaver', false), + subtitleEnabled: boolean('subtitleEnabled', true), + subtitleFontFamily: safeString('subtitleFontFamily', 'sans-serif'), + videoFit: choice('videoFit', {'contain', 'cover', 'fill'}, 'contain'), + audioOnly: boolean('audioOnly', false), + playerVolume: number('playerVolume', 1, min: 0, max: 1), + rememberVolume: boolean('rememberVolume', false), + backgroundAudio: boolean('backgroundAudio', false), + closeOnCompletion: boolean('closeOnCompletion', false), + resumeRewindSeconds: integer('resumeRewindSeconds', 5, 0, 120), + parallelDownloads: integer('parallelDownloads', 3, 1, 8), + compactCatalog: boolean('compactCatalog', false), + introCompleted: boolean('introCompleted', true), + externalPlayerPackage: safeString( + 'externalPlayerPackage', + 'ask', + package: true, + ), + ); + } +} + +class WatchEntry { + WatchEntry({ + required this.title, + this.episodeId, + this.episodeTitle = '', + this.sourceUrl = '', + this.positionSeconds = 0, + this.durationSeconds = 0, + DateTime? updatedAt, + }) : updatedAt = (updatedAt ?? DateTime.now()).toUtc(); + + final TitleRecord title; + final int? episodeId; + final String episodeTitle, sourceUrl; + final double positionSeconds, durationSeconds; + final DateTime updatedAt; + + double get progress => + durationSeconds > 0 && + durationSeconds.isFinite && + positionSeconds.isFinite + ? (positionSeconds / durationSeconds).clamp(0.0, 1.0) + : 0; + + Map toJson() => { + 'title': title.toJson(), + 'episodeId': episodeId, + 'episodeTitle': episodeTitle, + 'sourceUrl': sourceUrl, + 'positionSeconds': positionSeconds, + 'durationSeconds': durationSeconds, + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory WatchEntry.fromJson(Map json) { + final episode = json['episodeId']; + final position = json['positionSeconds']; + final duration = json['durationSeconds']; + final timestamp = json['updatedAt']; + final date = timestamp is String ? DateTime.tryParse(timestamp) : null; + if ((episode != null && (episode is! int || episode <= 0)) || + json['episodeTitle'] is! String || + json['sourceUrl'] is! String || + position is! num || + !position.isFinite || + position < 0 || + duration is! num || + !duration.isFinite || + duration < 0 || + date == null) { + throw const FormatException('Invalid watch entry'); + } + return WatchEntry( + title: _parseTitles([json['title']]).single, + episodeId: episode as int?, + episodeTitle: ContentPolicy.sanitizeText(json['episodeTitle'] as String), + sourceUrl: ContentPolicy.allowsUrl(json['sourceUrl'] as String) + ? json['sourceUrl'] as String + : '', + positionSeconds: position.toDouble(), + durationSeconds: duration.toDouble(), + updatedAt: date, + ); + } +} + +class LocalCollection { + LocalCollection({ + required this.id, + required this.name, + List titles = const [], + }) : titles = List.unmodifiable(titles); + + final String id, name; + final List titles; + + Map toJson() => { + 'id': id, + 'name': name, + 'titles': titles.map((title) => title.toJson()).toList(), + }; + + factory LocalCollection.fromJson(Map json) { + final id = json['id']; + final name = json['name']; + if (id is! String || + id.trim().isEmpty || + name is! String || + name.trim().isEmpty) { + throw const FormatException('Invalid local collection'); + } + return LocalCollection( + id: id, + name: ContentPolicy.allowsText(name) ? name : 'Local collection', + titles: _parseTitles(json['titles']), + ); + } +} + +bool _allowsPersistedTitle(Map json) => + json['type'] != 'channel' && + json['kind'] != 'channel' && + ContentPolicy.allowsTitleJson(json); + +/// A deliberate completion marker, not a playback position. +class EpisodeWatchMarker { + const EpisodeWatchMarker({ + required this.title, + required this.episodeId, + this.episodeTitle = '', + }); + final TitleRecord title; + final int episodeId; + final String episodeTitle; + Map toJson() => { + 'title': title.toJson(), + 'episodeId': episodeId, + 'episodeTitle': episodeTitle, + }; +} + +List _parseMarkers(dynamic value) { + if (value == null) return const []; // Backward-compatible v1 extension. + if (value is! List) { + throw const FormatException('Expected watched episode markers'); + } + final seen = {}; + final result = []; + for (final item in value) { + final json = _object(item); + final titleJson = _object(json['title']); + if (!_allowsPersistedTitle(titleJson)) continue; + final title = _parseTitles([titleJson]).single; + final episodeId = json['episodeId']; + if (episodeId is! int || + episodeId <= 0 || + json['episodeTitle'] is! String || + !seen.add('${title.libraryKey}:$episodeId')) { + throw const FormatException( + 'Invalid or duplicate watched episode marker', + ); + } + result.add( + EpisodeWatchMarker( + title: title, + episodeId: episodeId, + episodeTitle: ContentPolicy.sanitizeText( + json['episodeTitle'] as String, + ), + ), + ); + } + return result; +} + +class HistoryContinuation { + const HistoryContinuation({ + required this.title, + required this.source, + this.episode, + this.playlist = const [], + this.alternatives = const [], + }); + final TitleRecord title; + final MediaSource source; + final EpisodeRecord? episode; + final List playlist; + final List alternatives; +} + +class _Library { + _Library({ + AppSettings? prefs, + List saved = const [], + List history = const [], + List searchHistory = const [], + List collections = const [], + List watchedEpisodes = const [], + }) : prefs = prefs ?? AppSettings(), + saved = List.unmodifiable(saved), + history = List.unmodifiable(history), + searchHistory = List.unmodifiable(searchHistory), + collections = List.unmodifiable(collections), + watchedEpisodes = List.unmodifiable(watchedEpisodes); + + final AppSettings prefs; + final List saved; + final List history; + final List searchHistory; + final List collections; + final List watchedEpisodes; + + _Library copyWith({ + AppSettings? prefs, + List? saved, + List? history, + List? searchHistory, + List? collections, + List? watchedEpisodes, + }) => _Library( + prefs: prefs ?? this.prefs, + saved: saved ?? this.saved, + history: history ?? this.history, + searchHistory: searchHistory ?? this.searchHistory, + collections: collections ?? this.collections, + watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes, + ); + + Map toJson() => { + 'format': 'reelbox.library', + 'version': 1, + 'settings': prefs.toJson(), + 'saved': saved.map((title) => title.toJson()).toList(), + 'history': history.map((entry) => entry.toJson()).toList(), + 'searchHistory': searchHistory, + 'collections': collections.map((item) => item.toJson()).toList(), + 'watchedEpisodes': watchedEpisodes.map((item) => item.toJson()).toList(), + }; + + factory _Library.fromJson(Map json) { + if (json['format'] != 'reelbox.library' || + json['version'] != 1 || + json['version'] is! int) { + throw const FormatException('Unsupported Reelbox library format/version'); + } + final titles = _parseTitles(json['saved']); + final watch = json['history']; + if (watch is! List) throw const FormatException('Expected watch history'); + final entries = watch + .where( + (value) => _allowsPersistedTitle(_object(_object(value)['title'])), + ) + .map((value) => WatchEntry.fromJson(_object(value))) + .toList(); + final keys = {}; + for (final entry in entries) { + if (!keys.add('${entry.title.libraryKey}:${entry.episodeId}')) { + throw const FormatException('Duplicate watch entry'); + } + } + entries.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + final rawCollections = json['collections']; + if (rawCollections is! List) { + throw const FormatException('Expected local collections'); + } + final collections = rawCollections + .map((item) => LocalCollection.fromJson(_object(item))) + .toList(); + final collectionIds = {}; + if (collections.any((item) => !collectionIds.add(item.id))) { + throw const FormatException('Duplicate local collection ID'); + } + return _Library( + prefs: AppSettings.fromJson(_object(json['settings'])), + saved: titles, + history: entries.take(1000).toList(), + searchHistory: _parseSearchHistory(json['searchHistory']), + collections: collections, + watchedEpisodes: _parseMarkers(json['watchedEpisodes']), + ); + } +} + +List _parseSearchHistory(dynamic value) { + if (value is! List || value.any((item) => item is! String)) { + throw const FormatException('Expected search history strings'); + } + final seen = {}; + return value + .cast() + .map((item) => item.trim()) + .where( + (item) => + item.isNotEmpty && + ContentPolicy.allowsText(item) && + seen.add(item.toLowerCase()), + ) + .take(30) + .toList(); +} + +Map _object(dynamic value) { + if (value is! Map) { + throw const FormatException('Expected a JSON object'); + } + return value; +} + +List _parseTitles(dynamic value) { + if (value is! List) throw const FormatException('Expected a title list'); + final ids = {}; + return value + .where((item) => _allowsPersistedTitle(_object(item))) + .map((item) { + final json = _object(item); + final id = json['id']; + if (id is! int || id <= 0 || json['title'] is! String) { + throw const FormatException('Invalid or duplicate title'); + } + final title = TitleRecord.fromJson(json); + if (!ids.add(title.libraryKey)) { + throw const FormatException('Invalid or duplicate title'); + } + return title; + }) + .where((title) => _allowsPersistedTitle(title.toJson())) + .toList(); +} + +/// All mutations are queued, durably written as one versioned JSON value, then +/// published. Await returned futures to surface storage and validation errors. +class AppState extends ChangeNotifier { + AppState({ApiService? api, this._storage}) : api = api ?? ApiService(); + + static const storageKey = 'reelbox.local.library.v1'; + final ApiService api; + SharedPreferences? _storage; + _Library _data = _Library(); + Future _pending = Future.value(); + bool _loaded = false; + bool _disposed = false; + bool _initialized = false; + + int _apiRevision = 0; + int get apiRevision => _apiRevision; + bool get usesIranCatalogue => api.baseUrl == ApiService.iranBaseUrl; + + /// Only explicit user selection changes catalogue origin; local data is untouched. + Future selectApi(String baseUrl) => _enqueue(() async { + if (baseUrl != ApiService.iranBaseUrl) { + throw const ApiException('Iran Catalogue is the supported API server.'); + } + await _load(); + if (api.baseUrl == baseUrl) return; + await api.setBaseUrl(baseUrl); + _apiRevision++; + if (!_disposed) notifyListeners(); + }); + + AppSettings get prefs => _data.prefs; + String get locale => prefs.locale; + List get selectedGenres => prefs.preferredGenres; + List get saved => _data.saved; + List get history => _data.history; + List get searchHistory => _data.searchHistory; + List get collections => _data.collections; + + /// Legacy integer-only callers refer to the poster namespace. + bool hasSaved(int id, {String kind = 'poster'}) => + saved.any((title) => title.libraryKey == _libraryKey(id, kind)); + + Future _enqueue(Future Function() action) { + final result = _pending.then((_) { + if (_disposed) throw StateError('AppState is disposed'); + return action(); + }); + // A failed caller-visible operation must not poison later operations. + _pending = result.then( + (_) {}, + onError: (Object error, StackTrace stack) {}, + ); + return result; + } + + Future _load() async { + if (_loaded) return; + final storage = _storage ??= await SharedPreferences.getInstance(); + final encoded = storage.getString(storageKey); + if (encoded != null) { + final migrated = _Library.fromJson(_object(jsonDecode(encoded))); + final sanitized = jsonEncode(migrated.toJson()); + // Retain the same v1 namespace and all allowed user data. Do not reset a + // failed/corrupt load; surface errors and keep its original backup intact. + if (sanitized != encoded && + !await storage.setString(storageKey, sanitized)) { + throw StateError('Could not persist the local library migration'); + } + _data = migrated; + } + _loaded = true; + } + + Future initialize({bool discover = true}) => _enqueue(() async { + if (_initialized) return; + await _load(); + await api.initialize(discover: discover); + _initialized = true; + if (!_disposed) notifyListeners(); + }); + + Future _commit(_Library next) async { + final safeNext = _Library.fromJson(next.toJson()); + final encoded = jsonEncode(safeNext.toJson()); + final success = await _storage!.setString(storageKey, encoded); + if (!success) { + throw StateError('Could not persist the local Reelbox library'); + } + _data = safeNext; + if (!_disposed) notifyListeners(); + } + + Future toggleSaved(TitleRecord title) => _enqueue(() async { + await _load(); + if (!_allowsPersistedTitle(title.toJson())) return; + if (title.id <= 0) throw ArgumentError.value(title.id, 'title.id'); + await _commit( + _data.copyWith( + saved: hasSaved(title.id, kind: title.type) + ? saved + .where((item) => item.libraryKey != title.libraryKey) + .toList() + : [title, ...saved], + ), + ); + }); + + Future removeSaved(int id, {String kind = 'poster'}) => + _enqueue(() async { + await _load(); + await _commit( + _data.copyWith( + saved: saved + .where((title) => title.libraryKey != _libraryKey(id, kind)) + .toList(), + ), + ); + }); + + Future updateSettings(AppSettings settings) => _enqueue(() async { + await _load(); + await _commit( + _data.copyWith(prefs: AppSettings.fromJson(settings.toJson())), + ); + }); + + Future rememberSearch(String query) => _enqueue(() async { + await _load(); + final text = query.trim(); + if (text.isEmpty || !ContentPolicy.allowsText(text)) return; + await _commit( + _data.copyWith( + searchHistory: [ + text, + ...searchHistory.where( + (item) => item.toLowerCase() != text.toLowerCase(), + ), + ].take(30).toList(), + ), + ); + }); + + Future removeSearch(String query) => _enqueue(() async { + await _load(); + final key = query.trim().toLowerCase(); + await _commit( + _data.copyWith( + searchHistory: searchHistory + .where((item) => item.toLowerCase() != key) + .toList(), + ), + ); + }); + + Future clearSearchHistory() => _enqueue(() async { + await _load(); + await _commit(_data.copyWith(searchHistory: [])); + }); + + Future recordWatch( + TitleRecord title, { + int? episodeId, + String episodeTitle = '', + String sourceUrl = '', + double positionSeconds = 0, + double durationSeconds = 0, + }) => _enqueue(() async { + await _load(); + if (title.id <= 0 || + (episodeId != null && episodeId <= 0) || + !positionSeconds.isFinite || + positionSeconds < 0 || + !durationSeconds.isFinite || + durationSeconds < 0) { + throw ArgumentError( + 'Watch IDs and positions must be valid and non-negative', + ); + } + if (!_allowsPersistedTitle(title.toJson())) return; + final entry = WatchEntry( + title: title, + episodeId: episodeId, + episodeTitle: episodeTitle, + sourceUrl: sourceUrl, + positionSeconds: positionSeconds, + durationSeconds: durationSeconds, + ); + await _commit( + _data.copyWith( + history: [ + entry, + ...history.where( + (item) => + item.title.libraryKey != title.libraryKey || + item.episodeId != episodeId, + ), + ].take(1000).toList(), + ), + ); + }); + + double resumeSeconds(int titleId, {int? episodeId, String kind = 'poster'}) { + if (!prefs.resumePlayback) return 0; + for (final entry in history) { + if (entry.title.libraryKey == _libraryKey(titleId, kind) && + entry.episodeId == episodeId) { + return entry.positionSeconds; + } + } + return 0; + } + + /// Removes only the exact namespace/title/episode tuple. + /// Legacy callers default to posters; null identifies a non-episode entry. + Future removeHistory( + int titleId, { + int? episodeId, + String kind = 'poster', + }) => _enqueue(() async { + await _load(); + await _commit( + _data.copyWith( + history: history + .where( + (entry) => + entry.title.libraryKey != _libraryKey(titleId, kind) || + entry.episodeId != episodeId, + ) + .toList(), + ), + ); + }); + + Future clearHistory() => _enqueue(() async { + await _load(); + await _commit(_data.copyWith(history: [])); + }); + + Future createCollection(String name) => _enqueue(() async { + await _load(); + final text = name.trim(); + if (text.isEmpty || !ContentPolicy.allowsText(text)) { + throw ArgumentError('A valid local collection name is required'); + } + final base = 'local-${DateTime.now().microsecondsSinceEpoch}'; + var id = base; + var suffix = 0; + while (collections.any((item) => item.id == id)) { + id = '$base-${++suffix}'; + } + final collection = LocalCollection(id: id, name: text); + await _commit(_data.copyWith(collections: [...collections, collection])); + return collection; + }); + + Future deleteCollection(String collectionId) => _enqueue(() async { + await _load(); + await _commit( + _data.copyWith( + collections: collections + .where((item) => item.id != collectionId) + .toList(), + ), + ); + }); + + Future toggleCollectionTitle(String collectionId, TitleRecord title) => + _enqueue(() async { + await _load(); + if (!_allowsPersistedTitle(title.toJson())) return; + if (title.id <= 0) throw ArgumentError.value(title.id, 'title.id'); + final index = collections.indexWhere((item) => item.id == collectionId); + if (index < 0) throw StateError('Local collection not found'); + final collection = collections[index]; + final hasTitle = collection.titles.any( + (item) => item.libraryKey == title.libraryKey, + ); + final updated = LocalCollection( + id: collection.id, + name: collection.name, + titles: hasTitle + ? collection.titles + .where((item) => item.libraryKey != title.libraryKey) + .toList() + : [...collection.titles, title], + ); + final next = [...collections]; + next[index] = updated; + await _commit(_data.copyWith(collections: next)); + }); + + int get savedCount => saved.length; + int get historyCount => history.length; + int get collectionCount => collections.length; + int get watchedEpisodeCount => _data.watchedEpisodes.length; + List get watchedEpisodes => _data.watchedEpisodes; + + bool isEpisodeWatched(int titleId, int episodeId) => + _data.watchedEpisodes.any( + (entry) => entry.title.id == titleId && entry.episodeId == episodeId, + ); + + Future setEpisodeWatched( + TitleRecord title, + EpisodeRecord episode, + bool watched, + ) => _enqueue(() async { + await _load(); + if (!_allowsPersistedTitle(title.toJson())) return; + if (title.id <= 0 || episode.id <= 0) { + throw ArgumentError('Positive title and episode IDs are required'); + } + final next = _data.watchedEpisodes + .where( + (entry) => + entry.title.libraryKey != title.libraryKey || + entry.episodeId != episode.id, + ) + .toList(); + if (watched) { + next.add( + EpisodeWatchMarker( + title: title, + episodeId: episode.id, + episodeTitle: ContentPolicy.sanitizeText(episode.title), + ), + ); + } + await _commit(_data.copyWith(watchedEpisodes: next)); + }); + + /// Does not touch audio delay, playback preferences, downloads or the library. + Future resetSubtitleStyle() => _enqueue(() async { + await _load(); + await _commit( + _data.copyWith( + prefs: prefs.copyWith( + subtitleEnabled: true, + subtitleFontFamily: 'sans-serif', + subtitleFontSize: 24, + subtitleColor: 0xFFFFFFFF, + subtitleBackground: 0x80000000, + subtitleDelay: 0, + ), + ), + ); + }); + + /// Stored source URLs can expire. Always fetch current metadata before playing. + Future resolveHistory(WatchEntry entry) async { + if (!_allowsPersistedTitle(entry.title.toJson())) { + throw StateError('This title is no longer available'); + } + final title = await api.poster(entry.title.id); + if (!_allowsPersistedTitle(title.toJson())) { + throw StateError('This title is no longer available'); + } + EpisodeRecord? episode; + List playlist = const []; + if (entry.episodeId != null) { + playlist = (await api.seasons(title.id)) + .expand((season) => season.episodes) + .toList(); + final matches = playlist.where((item) => item.id == entry.episodeId); + if (matches.isEmpty) { + throw StateError('This episode is no longer available'); + } + episode = matches.first; + } + final sources = (episode?.sources ?? title.sources).where((source) { + final uri = Uri.tryParse(source.url); + return uri != null && + {'http', 'https'}.contains(uri.scheme) && + uri.host.isNotEmpty && + ContentPolicy.allowsUrl(source.url) && + (!prefs.hideTrailers || !source.isTrailer); + }).toList(); + if (sources.isEmpty) { + throw StateError('No current playable sources. Open the title to retry.'); + } + final preferred = sources.where( + (source) => + source.url == entry.sourceUrl || + (prefs.defaultQuality != 'ask' && + prefs.defaultQuality != 'auto' && + source.quality.contains(prefs.defaultQuality)), + ); + return HistoryContinuation( + title: title, + source: preferred.firstOrNull ?? sources.first, + episode: episode, + playlist: playlist, + alternatives: sources, + ); + } + + String exportLibrary() => jsonEncode(_data.toJson()); + + Future importLibrary(String text) => _enqueue(() async { + if (utf8.encode(text).length > 8 * 1024 * 1024) { + throw const FormatException('Library backup exceeds 8 MB'); + } + final next = _Library.fromJson(_object(jsonDecode(text))); + await _load(); + await _commit(next); + }); + + Future clearLocalData() => _enqueue(() async { + await _load(); + await _commit(_Library()); + }); + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class AppScope extends InheritedNotifier { + const AppScope({ + super.key, + AppState? state, + AppState? notifier, + required super.child, + }) : assert(state != null || notifier != null), + super(notifier: state ?? notifier); + + static AppState of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + if (scope?.notifier == null) throw StateError('AppScope is missing'); + return scope!.notifier!; + } + + static AppState read(BuildContext context) { + final scope = context.getInheritedWidgetOfExactType(); + if (scope?.notifier == null) throw StateError('AppScope is missing'); + return scope!.notifier!; + } +} + +String tr(BuildContext context, String english, String persian) => + AppScope.of(context).locale == 'fa' ? persian : english; diff --git a/lib/state/cache_service.dart b/lib/state/cache_service.dart new file mode 100644 index 0000000..f179b85 --- /dev/null +++ b/lib/state/cache_service.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:path_provider/path_provider.dart'; + +class CacheSnapshot { + const CacheSnapshot({required this.memoryBytes, required this.diskBytes}); + final int memoryBytes, diskBytes; + int get totalBytes => memoryBytes + diskBytes; +} + +/// Disposable cache only. Never sweep the temporary/app-support directories: +/// downloads, pending packages and private library data may also live there. +class CacheService { + CacheService({Future Function()? directory}) + : _directory = directory ?? _defaultDirectory; + final Future Function() _directory; + + static Future _defaultDirectory() async => + Directory('${(await getTemporaryDirectory()).path}/reelbox-cache'); + + Future measure() async { + var diskBytes = 0; + if (!kIsWeb) { + final directory = await _directory(); + // Ignore links even when the cache root itself is one. + if (await FileSystemEntity.type(directory.path, followLinks: false) == + FileSystemEntityType.directory) { + await for (final entry in directory.list( + recursive: true, + followLinks: false, + )) { + if (await FileSystemEntity.type(entry.path, followLinks: false) == + FileSystemEntityType.file) { + try { + diskBytes += await File(entry.path).length(); + } on FileSystemException { + // An evicted file may disappear during measurement. + } + } + } + } + } + return CacheSnapshot( + memoryBytes: PaintingBinding.instance.imageCache.currentSizeBytes, + diskBytes: diskBytes, + ); + } + + Future clear() async { + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + if (kIsWeb) return; + final directory = await _directory(); + if (await FileSystemEntity.type(directory.path, followLinks: false) != + FileSystemEntityType.directory) { + return; + } + // Only regular files. Do not follow or delete links to other storage. + await for (final entry in directory.list( + recursive: true, + followLinks: false, + )) { + if (await FileSystemEntity.type(entry.path, followLinks: false) == + FileSystemEntityType.file) { + try { + await File(entry.path).delete(); + } on FileSystemException { + if (await File(entry.path).exists()) rethrow; + } + } + } + } +} diff --git a/lib/ui/catalog.dart b/lib/ui/catalog.dart new file mode 100644 index 0000000..6fb834b --- /dev/null +++ b/lib/ui/catalog.dart @@ -0,0 +1,2363 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../data/api_service.dart'; +import '../data/catalog_discovery.dart'; +import '../data/content_policy.dart'; +import '../data/models.dart'; +import '../data/home_banners.dart'; +import '../media/player_page.dart'; +import '../media/media_platform.dart'; +import '../media/download_button.dart'; +import '../state/app_state.dart'; +import 'common.dart'; +import 'home_banners.dart'; +part 'catalog_extras.dart'; + +void _push(BuildContext context, Widget page) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + +List _titles(dynamic value) => value is List + ? value + .whereType() + .where( + (row) => + ContentPolicy.allowsTitleJson(Map.from(row)), + ) + .map((e) => TitleRecord.fromJson(Map.from(e))) + .where((e) => e.id > 0 && !e.isChannel) + .toList() + : []; +List> _maps(dynamic value) => value is List + ? value.whereType().map((e) => Map.from(e)).toList() + : []; +String _text(Map item, List keys) { + for (final key in keys) { + if (item[key] != null && '${item[key]}'.isNotEmpty) return '${item[key]}'; + } + return ''; +} + +int _id(Map item, List keys) => + int.tryParse(_text(item, keys)) ?? 0; + +class DiscoverPage extends StatefulWidget { + const DiscoverPage({super.key}); + @override + State createState() => _DiscoverPageState(); +} + +class _DiscoverPageState extends State { + Future>? _home; + Future>? _banners; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _home ??= AppScope.read(context).api.home(); + _banners ??= HomeBannerService(AppScope.read(context).api).load(); + } + + void _retry() => setState(() { + _home = AppScope.read(context).api.home(); + _banners = HomeBannerService(AppScope.read(context).api).load(); + }); + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: RefreshIndicator( + onRefresh: () async { + _retry(); + try { + await _home; + } catch (_) {} + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(22, 18, 12, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'REELBOX', + style: TextStyle( + letterSpacing: 4, + fontSize: 12, + fontWeight: FontWeight.w800, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 6), + Text( + tr( + context, + 'Find your next story.', + 'داستان بعدی‌ات را پیدا کن.', + ), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + ], + ), + ), + IconButton( + onPressed: () => + _push(context, const DiscoveryDirectoryPage()), + tooltip: tr(context, 'Explore more', 'کشف بیشتر'), + icon: const Icon(Icons.explore_outlined), + ), + IconButton( + onPressed: () => _push(context, const SearchPage()), + tooltip: tr(context, 'Search', 'جستجو'), + icon: const Icon(Icons.search_rounded), + ), + ], + ), + ), + HomeBanners(future: _banners!), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + spacing: 8, + children: [ + _shortcut( + context, + 'Movies', + 'فیلم‌ها', + Icons.movie_outlined, + const BrowsePage(), + ), + _shortcut( + context, + 'Series', + 'سریال‌ها', + Icons.video_library_outlined, + const BrowsePage(initialKind: 'serie'), + ), + _shortcut( + context, + 'Anime & animation', + 'انیمه و انیمیشن', + Icons.auto_awesome_outlined, + const BrowsePage(initialKind: 'mixed', initialGenre: 3), + ), + _shortcut( + context, + 'Actors', + 'بازیگران', + Icons.people_outline, + const ActorsPage(), + ), + _shortcut( + context, + 'Countries', + 'کشورها', + Icons.public, + const CountriesPage(), + ), + ], + ), + ), + AsyncResult>( + future: _home!, + onRetry: _retry, + builder: (data) { + final rails = _maps(data['genres']) + .where( + (row) => ContentPolicy.allowsText(_text(row, ['title'])), + ) + .toList(); + final featured = [ + ..._titles(data['posters']), + ...rails.expand((g) => _titles(g['posters'])), + ]; + final slides = _maps(data['slides']) + .where( + (slide) => + ContentPolicy.allowsText( + _text(slide, ['title', 'name']), + ) && + ContentPolicy.allowsUrl(_text(slide, ['url'])) && + ContentPolicy.allowsUrl(_text(slide, ['image'])), + ) + .toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (featured.isNotEmpty) _CinemaHero(title: featured.first), + if (slides.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: SizedBox( + height: 100, + child: PageView( + children: [ + for (final slide in slides) + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: () => _openWeb( + context, + _text(slide, ['url']), + ), + child: Stack( + fit: StackFit.expand, + children: [ + MediaArtwork( + url: _text(slide, ['image']), + ), + const PositionedDirectional( + end: 10, + bottom: 10, + child: Icon( + Icons.open_in_new_rounded, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + for (final rail in rails) + if (_titles(rail['posters']).isNotEmpty) + PosterRail( + title: _text(rail, ['title']), + items: _titles(rail['posters']), + onMore: _id(rail, ['id']) > 0 + ? () => _push( + context, + BrowsePage( + initialKind: 'mixed', + initialGenre: _id(rail, ['id']), + heading: _text(rail, ['title']), + ), + ) + : null, + ), + if (featured.isEmpty) + EmptyView( + title: tr( + context, + 'No discoveries yet', + 'هنوز محتوایی وجود ندارد', + ), + onRetry: _retry, + ), + ], + ); + }, + ), + const _DiscoveryHomeShelves(), + const _ContinueWatchingShelf(), + for (final genre in AppScope.of( + context, + ).prefs.preferredGenres.take(3)) + _RecommendationRail( + key: ValueKey('preference:$genre'), + genre: genre, + ), + const SizedBox(height: 18), + ], + ), + ), + ), + ); + Widget _shortcut( + BuildContext context, + String en, + String fa, + IconData icon, + Widget page, + ) => ActionChip( + avatar: Icon(icon, size: 17), + label: Text(tr(context, en, fa)), + onPressed: () => _push(context, page), + ); +} + +class _CinemaHero extends StatelessWidget { + const _CinemaHero({required this.title}); + final TitleRecord title; + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: SizedBox( + height: MediaQuery.sizeOf(context).width > 700 ? 400 : 345, + child: Stack( + fit: StackFit.expand, + children: [ + MediaArtwork( + url: title.cover.isNotEmpty ? title.cover : title.image, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x18000000), Color(0xF5080A0F)], + stops: [.1, 1], + ), + ), + ), + PositionedDirectional( + start: 22, + end: 22, + bottom: 24, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr(context, 'IN THE SPOTLIGHT', 'در کانون توجه'), + style: const TextStyle( + color: Color(0xFFFFA298), + letterSpacing: 2, + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + title.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 32, + height: 1.05, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 10), + Text( + [ + if (title.year > 0) '${title.year}', + if (title.imdb > 0) '★ ${title.imdb.toStringAsFixed(1)}', + ...title.genres.take(2).map((g) => g.title), + ].join(' · '), + style: const TextStyle( + color: Color(0xFFDDDEE3), + fontSize: 12, + ), + ), + const SizedBox(height: 18), + Wrap( + spacing: 10, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: () => goTitle(context, title), + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + tr(context, 'Explore title', 'مشاهده عنوان'), + ), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), + onPressed: () => _save(context, title), + icon: Icon( + AppScope.of(context) + .hasSaved(title.id, kind: title.type) + ? Icons.check + : Icons.add, + ), + label: Text(tr(context, 'Watchlist', 'تماشای بعدی')), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); +} + +Future _save(BuildContext context, TitleRecord title) async { + final state = AppScope.read(context); + try { + await state.toggleSaved(title); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not save locally. Please retry.', + 'ذخیره محلی ممکن نشد. دوباره تلاش کنید.', + ), + ); + } + } +} + +Future _openWeb(BuildContext context, String value) async { + final uri = Uri.tryParse(value); + if (!ContentPolicy.allowsUrl(value) || + uri == null || + !{'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty) { + toast( + context, + tr(context, 'This link is unavailable.', 'این پیوند در دسترس نیست.'), + ); + return; + } + try { + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw StateError('Unavailable'); + } + } catch (_) { + if (context.mounted) { + toast( + context, + tr(context, 'Could not open this link.', 'باز کردن پیوند ممکن نشد.'), + ); + } + } +} + +/// Paged requests retain successful pages and the raw server cursor. +class _PagedTitles extends StatefulWidget { + const _PagedTitles({ + super.key, + required this.load, + this.firstPage = 1, + this.headerBuilder, + }) : header = null; + final Future> Function(int page) load; + final int firstPage; + final Widget? header; + final Widget Function()? headerBuilder; + @override + State<_PagedTitles> createState() => _PagedTitlesState(); +} + +class _PagedTitlesState extends State<_PagedTitles> { + final _items = []; + final _scroll = ScrollController(); + bool _busy = false, _end = false, _failed = false; + late int _page; + int _emptyPages = 0; + @override + void initState() { + super.initState(); + _page = widget.firstPage; + _scroll.addListener(_nearEnd); + _load(); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + void _nearEnd() { + if (!_failed && + _emptyPages < 4 && + _scroll.hasClients && + _scroll.position.extentAfter < 450) { + _load(automatic: true); + } + } + + void _fillViewport() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_busy && !_end && !_failed) _nearEnd(); + }); + } + + Future _load({bool automatic = false}) async { + if (_busy || _end || !mounted) return; + if (!automatic) _emptyPages = 0; + setState(() { + _busy = true; + _failed = false; + }); + try { + final next = await widget.load(_page); + if (!mounted) return; + final known = _items.map((e) => e.libraryKey).toSet(); + final fresh = next + .where( + (e) => + e.id > 0 && + e.title.isNotEmpty && + !e.isChannel && + ContentPolicy.allowsTitleJson(e.toJson()) && + known.add(e.libraryKey), + ) + .toList(); + setState(() { + _items.addAll(fresh); + _emptyPages = fresh.isEmpty ? _emptyPages + 1 : 0; + // Never derive exhaustion/cursor from locally visible or deduped rows. + _end = next is CatalogPage ? next.nextPage == null : next.isEmpty; + _page = next is CatalogPage ? next.nextPage ?? _page : _page + 1; + }); + } catch (_) { + if (mounted) setState(() => _failed = true); + } finally { + if (mounted) { + setState(() => _busy = false); + _fillViewport(); + } + } + } + + Future _refresh() async { + if (_busy) return; + setState(() { + _items.clear(); + _end = false; + _failed = false; + _emptyPages = 0; + _page = widget.firstPage; + }); + await _load(); + } + + @override + Widget build(BuildContext context) { + final compact = AppScope.of(context).prefs.compactCatalog; + return RefreshIndicator( + onRefresh: _refresh, + child: CustomScrollView( + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: Column(children: [ + if (widget.headerBuilder != null) widget.headerBuilder!(), + if (widget.header != null) widget.header!, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + '${_items.length} ${tr(context, 'titles loaded', 'عنوان بارگذاری شده')}', + ), + ), + IconButton( + tooltip: compact + ? tr(context, 'Poster grid', 'نمای پوستر') + : tr(context, 'Compact rows', 'ردیف‌های فشرده'), + icon: Icon(compact ? Icons.grid_view : Icons.view_list), + onPressed: () async { + try { + final state = AppScope.read(context); + await state.updateSettings( + state.prefs.copyWith(compactCatalog: !compact), + ); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not save preference', + 'ذخیره تنظیم ممکن نشد', + ), + ); + } + } + }, + ), + ], + ), + ), + ])), + if (_items.isNotEmpty) PosterGrid(items: _items, sliver: true), + SliverToBoxAdapter(child: Column(children: [ + if (_busy) + const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + if (_failed) + EmptyView( + title: tr( + context, + 'Could not load titles', + 'بارگذاری عنوان‌ها ممکن نشد', + ), + message: tr( + context, + 'Check your connection or try again later.', + 'اتصال را بررسی کنید یا بعداً دوباره تلاش کنید.', + ), + icon: Icons.wifi_off_rounded, + onRetry: _load, + ), + if (!_busy && !_failed && _end && _items.isEmpty) + EmptyView( + title: tr(context, 'No titles found', 'عنوانی پیدا نشد'), + message: tr( + context, + 'Try a different search or filter.', + 'جستجو یا فیلتر دیگری را امتحان کنید.', + ), + onRetry: _refresh, + ), + if (!_busy && !_failed && !_end) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + child: OutlinedButton.icon( + onPressed: _load, + icon: const Icon(Icons.expand_more), + label: Text(tr(context, 'Load more', 'نمایش بیشتر')), + ), + ), + if (_end && _items.isNotEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + tr( + context, + 'You’re all caught up', + 'همه موارد نمایش داده شد', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + const SizedBox(height: 16), + ])), + ], + ), + ); + } +} + +class BrowsePage extends StatefulWidget { + const BrowsePage({ + super.key, + this.initialKind = 'movie', + this.initialGenre = 0, + this.initialCountry = 0, + this.initialOrder = 'created', + this.heading, + }); + final String initialKind, initialOrder; + final int initialGenre, initialCountry; + final String? heading; + @override + State createState() => _BrowsePageState(); +} + +class _BrowsePageState extends State { + late String _kind; + late int _genre, _country; + String _order = 'created'; + Future>>? _filters; + int _apiRevision = -1; + @override + void initState() { + super.initState(); + _kind = {'movie', 'serie', 'mixed'}.contains(widget.initialKind) + ? widget.initialKind + : 'movie'; + _genre = widget.initialGenre; + _country = widget.initialCountry; + _order = widget.initialOrder; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final state = AppScope.of(context); + if (_apiRevision != state.apiRevision) { + _apiRevision = state.apiRevision; + if (state.usesIranCatalogue && _order == 'views') _order = 'created'; + _filters = _loadFilters(); + } + } + + Future>> _loadFilters() async { + final api = AppScope.read(context).api; + return Future.wait([api.genres(), api.countries()]); + } + + Future> _load(int page) { + final api = AppScope.read(context).api; + return api.catalog( + kind: _kind, + genre: _genre, + country: _country, + order: _order, + page: page, + ); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(widget.heading ?? tr(context, 'Browse', 'مرور')), + actions: [ + IconButton( + onPressed: () => _push(context, const SearchPage()), + icon: const Icon(Icons.search), + tooltip: tr(context, 'Search', 'جستجو'), + ), + ], + ), + body: Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(18, 4, 18, 12), + child: Row( + spacing: 8, + children: [ + for (final option in [ + ('movie', 'Movies', 'فیلم‌ها'), + ('serie', 'Series', 'سریال‌ها'), + ('mixed', 'All titles', 'همه عنوان‌ها'), + ('anime', 'Anime & animation', 'انیمه و انیمیشن'), + ]) + ChoiceChip( + label: Text(tr(context, option.$2, option.$3)), + selected: option.$1 == 'anime' + ? _kind == 'mixed' && _genre == 3 + : _kind == option.$1 && + !(option.$1 == 'mixed' && _genre == 3), + onSelected: (_) => setState(() { + _kind = option.$1 == 'anime' ? 'mixed' : option.$1; + _genre = option.$1 == 'anime' ? 3 : 0; + }), + ), + ], + ), + ), + FutureBuilder>>( + future: _filters, + builder: (context, snapshot) { + if (snapshot.hasError) { + return TextButton.icon( + onPressed: () => setState(() { + _filters = _loadFilters(); + }), + icon: const Icon(Icons.refresh), + label: Text( + tr( + context, + 'Retry loading filters', + 'تلاش دوباره برای فیلترها', + ), + ), + ); + } + if (!snapshot.hasData) return const LinearProgressIndicator(); + final genres = snapshot.data![0]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Wrap( + spacing: 10, + runSpacing: 8, + children: [ + _filterMenu( + context, + tr(context, 'Genre', 'ژانر'), + _genre, + genres, + (value) => setState(() => _genre = value), + ), + _filterMenu( + context, + tr(context, 'Country', 'کشور'), + _country, + snapshot.data![1], + (value) => setState(() => _country = value), + ), + PopupMenuButton( + tooltip: tr(context, 'Sort titles', 'مرتب‌سازی'), + initialValue: _order, + onSelected: (value) => setState(() => _order = value), + itemBuilder: (_) => [ + for (final option in [ + ('created', 'Recently added', 'تازه اضافه‌شده'), + ('rating', 'Audience rating', 'امتیاز کاربران'), + if (!AppScope.of(context).usesIranCatalogue) + ('views', 'Most viewed', 'پربازدیدترین'), + ('imdb', 'IMDb rating', 'امتیاز IMDb'), + ('year', 'Release year', 'سال انتشار'), + ('title', 'Title', 'عنوان'), + ]) + PopupMenuItem( + value: option.$1, + child: Text(tr(context, option.$2, option.$3)), + ), + ], + child: Chip( + avatar: const Icon(Icons.sort, size: 18), + label: Text(_sortLabel(context, _order)), + ), + ), + if (_genre != 0 || _country != 0 || _order != 'created') + ActionChip( + label: Text(tr(context, 'Reset', 'بازنشانی')), + avatar: const Icon(Icons.clear, size: 16), + onPressed: () => setState(() { + _genre = 0; + _country = 0; + _order = 'created'; + }), + ), + ], + ), + ); + }, + ), + Expanded( + child: _PagedTitles( + key: ValueKey('$_apiRevision:$_kind:$_genre:$_country:$_order'), + load: _load, + firstPage: 0, + ), + ), + ], + ), + ); +} + +String _sortLabel(BuildContext context, String order) => switch (order) { + 'rating' => tr(context, 'Audience rating', 'امتیاز کاربران'), + 'views' => tr(context, 'Most viewed', 'پربازدیدترین'), + 'imdb' => tr(context, 'IMDb rating', 'امتیاز IMDb'), + 'year' => tr(context, 'Release year', 'سال انتشار'), + 'title' => tr(context, 'Title', 'عنوان'), + _ => tr(context, 'Recently added', 'تازه اضافه‌شده'), +}; +Widget _filterMenu( + BuildContext context, + String label, + int current, + List options, + ValueChanged changed, +) => PopupMenuButton( + tooltip: label, + initialValue: current, + onSelected: changed, + itemBuilder: (_) => [ + PopupMenuItem(value: 0, child: Text(tr(context, 'All', 'همه'))), + for (final tag in options) + if (tag.id > 0 && ContentPolicy.allowsText(tag.title)) + PopupMenuItem(value: tag.id, child: Text(tag.title)), + ], + child: Chip( + avatar: const Icon(Icons.tune_rounded, size: 17), + label: Text( + options.where((e) => e.id == current).firstOrNull?.title ?? + (current == 0 ? label : '$label: $current'), + ), + ), +); + +class SearchPage extends StatefulWidget { + const SearchPage({super.key, this.initialQuery = ''}); + final String initialQuery; + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + final _input = TextEditingController(); + Future>? _trending; + Future? _suggestions; + Future>>? _filterOptions; + String _query = ''; + bool _english = false, _help = false; + String _kind = 'mixed'; + int _year = 0, _genre = 0, _country = 0; + double _rating = 0; + DiscoverySearchFilter get _filter => DiscoverySearchFilter( + kind: _kind, + minimumYear: _year, + minimumRating: _rating, + genre: _genre, + country: _country, + ); + @override + void initState() { + super.initState(); + _query = widget.initialQuery.trim(); + _input.text = _query; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final api = AppScope.read(context).api; + _trending ??= api.trending(); + _suggestions ??= CatalogDiscovery(api).suggestions(); + } + + @override + void dispose() { + _input.dispose(); + super.dispose(); + } + + void _submit(String value) { + final query = value.trim(); + _input.text = query; + setState(() => _query = query); + if (query.isNotEmpty && ContentPolicy.allowsText(query)) { + AppScope.read(context).rememberSearch(query).catchError((Object _) {}); + } + FocusScope.of(context).unfocus(); + } + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + return Scaffold( + appBar: AppBar( + title: Text(tr(context, 'Search', 'جستجو')), + actions: [ + IconButton( + tooltip: tr(context, 'Search help', 'راهنمای جستجو'), + icon: const Icon(Icons.help_outline), + onPressed: () => setState(() => _help = !_help), + ), + ], + ), + body: LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * .65, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: TextField( + controller: _input, + textInputAction: TextInputAction.search, + onSubmitted: _submit, + decoration: InputDecoration( + hintText: tr( + context, + 'Titles in Persian or English', + 'عنوان به فارسی یا انگلیسی', + ), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: IconButton( + onPressed: () => _submit(''), + tooltip: tr( + context, + 'Clear search', + 'پاک کردن جستجو', + ), + icon: const Icon(Icons.close), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + ), + ), + if (_help) + Padding( + padding: const EdgeInsets.all(12), + child: Text( + tr( + context, + 'Search in Persian or use English search for the original title. Advanced filters apply to loaded search results, not a separate server query. Counts show loaded matching titles, not the server total.', + 'به فارسی جستجو کنید یا برای عنوان اصلی جستجوی انگلیسی را انتخاب کنید. فیلترهای پیشرفته روی نتایج بارگذاری‌شده اعمال می‌شوند. شمارش، تعداد نتایج بارگذاری‌شده است نه کل سرویس.', + ), + ), + ), + ExpansionTile( + title: Text( + tr(context, 'Advanced filters', 'فیلترهای پیشرفته'), + ), + onExpansionChanged: (expanded) { + if (expanded && _filterOptions == null) { + setState(() { + _filterOptions = Future.wait([ + state.api.genres(), + state.api.countries(), + ]); + }); + } + }, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + FilterChip( + selected: _english, + label: Text( + tr( + context, + 'English search', + 'جستجوی انگلیسی', + ), + ), + onSelected: (value) => + setState(() => _english = value), + ), + for (final option in [ + ('mixed', 'All titles', 'همه عنوان‌ها'), + ('movie', 'Movies', 'فیلم‌ها'), + ('serie', 'Series', 'سریال‌ها'), + ]) + ChoiceChip( + label: Text( + tr(context, option.$2, option.$3), + ), + selected: _kind == option.$1, + onSelected: (_) => + setState(() => _kind = option.$1), + ), + PopupMenuButton( + tooltip: tr( + context, + 'Minimum year', + 'حداقل سال', + ), + onSelected: (value) => + setState(() => _year = value), + itemBuilder: (_) => [ + for (final value in [ + 0, + 2020, + 2010, + 2000, + 1990, + ]) + PopupMenuItem( + value: value, + child: Text( + value == 0 + ? tr( + context, + 'Any year', + 'همه سال‌ها', + ) + : '$value+', + ), + ), + ], + child: Chip( + label: Text( + _year == 0 + ? tr( + context, + 'Minimum year', + 'حداقل سال', + ) + : '$_year+', + ), + ), + ), + PopupMenuButton( + tooltip: tr( + context, + 'Minimum IMDb', + 'حداقل IMDb', + ), + onSelected: (value) => + setState(() => _rating = value), + itemBuilder: (_) => [ + for (final value in [0.0, 5.0, 6.0, 7.0, 8.0]) + PopupMenuItem( + value: value, + child: Text( + value == 0 + ? tr( + context, + 'Any rating', + 'همه امتیازها', + ) + : '$value+', + ), + ), + ], + child: Chip( + label: Text( + _rating == 0 + ? tr( + context, + 'Minimum IMDb', + 'حداقل IMDb', + ) + : 'IMDb $_rating+', + ), + ), + ), + ActionChip( + label: Text( + tr( + context, + 'Reset filters', + 'بازنشانی فیلترها', + ), + ), + onPressed: () => setState(() { + _english = false; + _kind = 'mixed'; + _year = _genre = _country = 0; + _rating = 0; + }), + ), + ], + ), + ), + if (_filterOptions != null) + FutureBuilder>>( + future: _filterOptions, + builder: (context, snapshot) { + if (snapshot.hasError) { + return TextButton( + onPressed: () => setState(() { + _filterOptions = Future.wait([ + state.api.genres(), + state.api.countries(), + ]); + }), + child: Text( + tr( + context, + 'Retry loading filters', + 'تلاش دوباره برای فیلترها', + ), + ), + ); + } + if (!snapshot.hasData) { + return const LinearProgressIndicator(); + } + return Wrap( + spacing: 8, + children: [ + _filterMenu( + context, + tr(context, 'Genre', 'ژانر'), + _genre, + snapshot.data![0], + (value) => setState(() => _genre = value), + ), + _filterMenu( + context, + tr(context, 'Country', 'کشور'), + _country, + snapshot.data![1], + (value) => setState(() => _country = value), + ), + ], + ); + }, + ), + ], + ), + Align( + alignment: AlignmentDirectional.centerEnd, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: IconButton.filledTonal( + onPressed: () => _submit(_input.text), + tooltip: tr(context, 'Search', 'جستجو'), + icon: const Icon(Icons.arrow_forward_rounded), + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: _query.isNotEmpty + ? _PagedTitles( + key: ValueKey('$_query:$_english:${_filter.key}'), + load: (page) => CatalogDiscovery(state.api).search( + _query, + page: page, + english: _english, + filter: _filter, + ), + ) + : ListView( + children: [ + if (state.searchHistory.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: _heading( + context, + tr( + context, + 'Recent searches', + 'جستجوهای اخیر', + ), + ), + ), + TextButton( + onPressed: () async { + try { + await state.clearSearchHistory(); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not clear history', + 'پاک کردن تاریخچه ممکن نشد', + ), + ); + } + } + }, + child: Text(tr(context, 'Clear', 'پاک کردن')), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 8, + children: [ + for (final query in state.searchHistory.where( + ContentPolicy.allowsText, + )) + InputChip( + label: Text(query), + avatar: const Icon(Icons.history, size: 16), + onPressed: () => _submit(query), + deleteButtonTooltipMessage: + '${tr(context, 'Remove', 'حذف')} $query', + onDeleted: () async { + try { + await state.removeSearch(query); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not remove search', + 'حذف جستجو ممکن نشد', + ), + ); + } + } + }, + ), + ], + ), + ), + ], + Padding( + padding: const EdgeInsets.all(20), + child: _heading( + context, + tr( + context, + 'Suggested titles', + 'عنوان‌های پیشنهادی', + ), + ), + ), + AsyncResult( + future: _suggestions!, + onRetry: () => setState(() { + _suggestions = CatalogDiscovery(state.api) + .suggestions(); + }), + builder: (titles) => titles.isEmpty + ? EmptyView( + title: tr( + context, + 'No suggestions available', + 'پیشنهادی موجود نیست', + ), + ) + : PosterGrid(items: titles.take(12).toList()), + ), + TextButton.icon( + onPressed: () => _push( + context, + const RecommendationsPage(byYear: true), + ), + icon: const Icon(Icons.shuffle), + label: Text( + tr(context, 'Best by year', 'بهترین‌های هر سال'), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: _heading( + context, + tr( + context, + 'Trending searches', + 'جستجوهای پرطرفدار', + ), + ), + ), + AsyncResult>( + future: _trending!, + onRetry: () => setState(() { + _trending = state.api.trending(); + }), + builder: (terms) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 8, + runSpacing: 6, + children: [ + for (final term in terms.where( + ContentPolicy.allowsText, + )) + ActionChip( + label: Text(term), + avatar: const Icon( + Icons.trending_up, + size: 16, + ), + onPressed: () => _submit(term), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +Widget _heading(BuildContext context, String text) => Text( + text, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), +); + +class TitlePage extends StatefulWidget { + const TitlePage({super.key, required this.title}); + final TitleRecord title; + @override + State createState() => _TitlePageState(); +} + +class _TitlePageState extends State { + Future? _detail; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _detail ??= _fetch(); + } + + Future _fetch() { + final request = _fetchTitle(); + // Retry may fail before the next frame attaches FutureBuilder's listener. + // Mark that error handled without changing the future the UI observes. + request.ignore(); + return request; + } + + Future _fetchTitle() async { + if (widget.title.isChannel || + !ContentPolicy.allowsTitleJson(widget.title.toJson())) { + throw const ApiException('This title is unavailable.'); + } + final title = await AppScope.read(context).api.poster(widget.title.id); + if (title.id <= 0 || + title.isChannel || + !ContentPolicy.allowsTitleJson(title.toJson())) { + throw const ApiException('This title is unavailable.'); + } + return title; + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + ContentPolicy.allowsTitleJson(widget.title.toJson()) && + !widget.title.isChannel + ? widget.title.title + : tr(context, 'Unavailable title', 'عنوان در دسترس نیست'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + body: SingleChildScrollView( + child: AsyncResult( + future: _detail!, + onRetry: () => setState(() { + _detail = _fetch(); + }), + builder: (title) => _TitleBody( + key: ValueKey('${title.type}:${title.id}'), + title: title, + ), + ), + ), + ); +} + +class _TitleBody extends StatelessWidget { + const _TitleBody({super.key, required this.title}); + final TitleRecord title; + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final api = state.api; + final genres = title.genres; + final website = _text(title.raw, ['website']); + final related = _titles(title.raw['related'] ?? title.raw['posters']); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).width > 700 ? 360 : 270, + child: Stack( + fit: StackFit.expand, + children: [ + MediaArtwork( + url: title.cover.isNotEmpty ? title.cover : title.image, + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Theme.of(context).scaffoldBackgroundColor, + ], + ), + ), + ), + PositionedDirectional( + start: 24, + end: 24, + bottom: 12, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: MediaArtwork( + url: title.image, + width: 100, + height: 148, + ), + ), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.isSeries + ? tr(context, 'SERIES', 'سریال') + : tr(context, 'MOVIE', 'فیلم'), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + letterSpacing: 2, + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + title.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w900), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + [ + if (title.year > 0) '${title.year}', + if (title.duration.isNotEmpty) title.duration, + if (title.classification.isNotEmpty) + title.classification, + ].join(' · '), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(22, 12, 22, 4), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (title.imdb > 0) + _badge(context, 'IMDb ★ ${title.imdb.toStringAsFixed(1)}'), + if (title.rating > 0) + _badge( + context, + '${tr(context, 'Audience', 'کاربران')} ${title.rating.toStringAsFixed(1)}', + ), + for (final country in title.countries) + ActionChip( + label: Text(country.title), + onPressed: () => _push( + context, + BrowsePage( + initialKind: 'mixed', + initialCountry: country.id, + heading: country.title, + ), + ), + ), + for (final genre in genres) + ActionChip( + label: Text(genre.title), + onPressed: () => _push( + context, + BrowsePage( + initialKind: 'mixed', + initialGenre: genre.id, + heading: genre.title, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(22, 12, 22, 8), + child: Wrap( + spacing: 10, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: () => _save(context, title), + icon: Icon( + state.hasSaved(title.id, kind: title.type) + ? Icons.bookmark + : Icons.bookmark_add_outlined, + ), + label: Text( + state.hasSaved(title.id, kind: title.type) + ? tr(context, 'In watchlist', 'در تماشای بعدی') + : tr( + context, + 'Add to watchlist', + 'افزودن به تماشای بعدی', + ), + ), + ), + OutlinedButton.icon( + onPressed: () => _shareTitle(context, title), + icon: const Icon(Icons.share_outlined), + label: Text(tr(context, 'Share', 'اشتراک‌گذاری')), + ), + if (website.isNotEmpty && ContentPolicy.allowsUrl(website)) + OutlinedButton.icon( + onPressed: () => _openWeb(context, website), + icon: const Icon(Icons.language), + label: Text(tr(context, 'Website', 'وب‌سایت')), + ), + ], + ), + ), + if (title.description.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(22, 16, 22, 8), + child: _Synopsis(text: title.description), + ), + if (title.isSeries) + _RemotePanel( + title: tr(context, 'Seasons & episodes', 'فصل‌ها و قسمت‌ها'), + initiallyExpanded: true, + load: () => api.seasons(title.id), + builder: (seasons) { + final playlist = seasons.expand((s) => s.episodes).toList(); + return Column( + children: [ + for (final season in seasons) + ExpansionTile( + title: Text( + season.title.isEmpty + ? '${tr(context, 'Season', 'فصل')} ${season.id}' + : season.title, + ), + subtitle: Text( + '${season.episodes.length} ${tr(context, 'episodes', 'قسمت')}', + ), + children: [ + if (season.episodes.isEmpty) + EmptyView( + title: tr( + context, + 'No episodes available', + 'قسمتی موجود نیست', + ), + ), + for (final episode in season.episodes) + _EpisodeTile( + title: title, + episode: episode, + playlist: playlist, + ), + ], + ), + ], + ); + }, + ), + if (!title.isSeries || title.sources.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(22, 18, 22, 8), + child: _heading(context, tr(context, 'Sources', 'منابع پخش')), + ), + if (!title.isSeries || title.sources.isNotEmpty) + _SourceList(title: title, sources: title.sources), + _RemotePanel>( + title: tr(context, 'Cast & crew', 'بازیگران و عوامل'), + load: () => api.roles(title.id), + builder: (actors) => _PeopleGrid(items: actors), + ), + if (!title.isSeries) + _RemotePanel>( + title: tr(context, 'Subtitles', 'زیرنویس‌ها'), + load: () => api.subtitles('movie', title.id), + builder: (items) => Column( + children: [for (final item in items) _SubtitleTile(item: item)], + ), + ), + + if (related.isNotEmpty) + PosterRail( + title: tr(context, 'Related titles', 'عنوان‌های مرتبط'), + items: related, + ), + if (title.genres.isNotEmpty) + _RecommendationRail( + genre: title.genres.first.id, + excludeId: title.id, + ), + const SizedBox(height: 32), + ], + ); + } +} + +Widget _badge(BuildContext context, String text) => DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Text( + text, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ), +); + +class _Synopsis extends StatefulWidget { + const _Synopsis({required this.text}); + final String text; + @override + State<_Synopsis> createState() => _SynopsisState(); +} + +class _SynopsisState extends State<_Synopsis> { + bool _expanded = false; + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _heading(context, tr(context, 'Story', 'داستان')), + const SizedBox(height: 10), + SelectableText( + widget.text, + maxLines: _expanded ? null : 6, + style: TextStyle( + height: 1.7, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (widget.text.length > 220) + TextButton( + onPressed: () => setState(() => _expanded = !_expanded), + child: Text( + _expanded + ? tr(context, 'Show less', 'نمایش کمتر') + : tr(context, 'Read more', 'بیشتر بخوانید'), + ), + ), + ], + ); +} + +class _EpisodeTile extends StatelessWidget { + const _EpisodeTile({ + required this.title, + required this.episode, + required this.playlist, + }); + final TitleRecord title; + final EpisodeRecord episode; + final List playlist; + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final watched = state.history + .where( + (entry) => + entry.title.libraryKey == title.libraryKey && + entry.episodeId == episode.id, + ) + .firstOrNull; + return ExpansionTile( + key: ValueKey('episode:${episode.id}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: state.isEpisodeWatched(title.id, episode.id) + ? tr(context, 'Mark unwatched', 'علامت ناتماشاشده') + : tr(context, 'Mark watched', 'علامت تماشاشده'), + icon: Icon( + state.isEpisodeWatched(title.id, episode.id) + ? Icons.check_circle + : Icons.radio_button_unchecked, + ), + onPressed: () async { + try { + await state.setEpisodeWatched( + title, + episode, + !state.isEpisodeWatched(title.id, episode.id), + ); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not save watched status', + 'ذخیره وضعیت تماشا ممکن نشد', + ), + ); + } + } + }, + ), + const Icon(Icons.expand_more), + ], + ), + leading: SizedBox( + width: 52, + height: 42, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: MediaArtwork(url: _text(episode.raw, ['image'])), + ), + ), + title: Text( + episode.title.isEmpty + ? '${tr(context, 'Episode', 'قسمت')} ${episode.id}' + : episode.title, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.isEpisodeWatched(title.id, episode.id) + ? tr(context, 'Watched', 'تماشاشده') + : tr(context, 'Unwatched', 'تماشانشده'), + ), + if (episode.duration.isNotEmpty) Text(episode.duration), + if (watched != null) ...[ + const SizedBox(height: 5), + LinearProgressIndicator(value: watched.progress), + const SizedBox(height: 3), + Text( + '${(watched.progress * 100).round()}% ${tr(context, 'watched', 'تماشاشده')}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ], + ), + children: [ + if (episode.description.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text(episode.description), + ), + _SourceList( + title: title, + sources: episode.sources, + episode: episode, + playlist: playlist, + ), + _RemotePanel>( + title: tr(context, 'Episode subtitles', 'زیرنویس قسمت'), + load: () => state.api.subtitles('episode', episode.id), + builder: (items) => Column( + children: [for (final item in items) _SubtitleTile(item: item)], + ), + ), + ], + ); + } +} + +class _SourceList extends StatelessWidget { + const _SourceList({ + required this.title, + required this.sources, + this.episode, + this.playlist = const [], + }); + final TitleRecord title; + final List sources; + final EpisodeRecord? episode; + final List playlist; + @override + Widget build(BuildContext context) { + final visible = sources + .where( + (s) => + ContentPolicy.allowsUrl(s.url) && + ContentPolicy.allowsText(s.quality) && + !(AppScope.of(context).prefs.hideTrailers && s.isTrailer), + ) + .toList(); + if (visible.isEmpty) { + return EmptyView( + title: tr( + context, + 'No public sources available', + 'منبع عمومی موجود نیست', + ), + message: tr( + context, + 'The service has not returned a playable source for this title.', + 'سرویس برای این عنوان منبع پخش برنگردانده است.', + ), + ); + } + return Column( + children: [ + if (visible.any((s) => !s.isTrailer && s.url.isNotEmpty)) + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + key: const ValueKey('play-preferred'), + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + tr(context, 'Play preferred source', 'پخش منبع ترجیحی'), + ), + onPressed: () async { + final quality = AppScope.read(context).prefs.defaultQuality; + final playable = visible + .where((s) => !s.isTrailer && s.url.isNotEmpty) + .toList(); + var preferred = + playable + .where( + (s) => RegExp( + r'(^|\D)' + RegExp.escape(quality) + r'(p\b|\D|$)', + caseSensitive: false, + ).hasMatch(s.quality), + ) + .firstOrNull ?? + playable.first; + if (quality == 'ask') { + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text( + tr(context, 'Choose a source', 'انتخاب منبع'), + ), + children: [ + for (final option in playable) + SimpleDialogOption( + onPressed: () => Navigator.pop(context, option), + child: Text( + option.quality.isEmpty + ? tr(context, 'Original source', 'منبع اصلی') + : option.quality, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(tr(context, 'Cancel', 'لغو')), + ), + ], + ), + ); + if (selected == null || !context.mounted) return; + preferred = selected; + } + if (!context.mounted) return; + await _play( + context, + title, + preferred, + episode: episode, + playlist: playlist, + alternatives: visible, + ); + }, + ), + ), + for (final source in visible) + Padding( + padding: const EdgeInsets.fromLTRB(18, 4, 18, 6), + child: Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 8, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + source.isTrailer + ? Icons.local_play_outlined + : Icons.high_quality_outlined, + size: 21, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + source.quality.isNotEmpty + ? source.quality + : tr(context, 'Original source', 'منبع اصلی'), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + if (source.type.isNotEmpty) + _badge(context, source.type.toUpperCase()), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + IconButton.filledTonal( + onPressed: source.url.isEmpty + ? null + : () => _play( + context, + title, + source, + episode: episode, + playlist: playlist, + alternatives: visible, + ), + tooltip: tr(context, 'Play', 'پخش'), + icon: const Icon(Icons.play_arrow_rounded), + ), + DownloadButton( + title: title, + source: source, + episode: episode, + ), + IconButton( + onPressed: source.url.isEmpty + ? null + : () => _copy(context, source.url), + tooltip: tr( + context, + 'Copy source URL', + 'کپی پیوند منبع', + ), + icon: const Icon(Icons.copy_all_outlined), + ), + IconButton( + onPressed: source.url.isEmpty + ? null + : () => _share(context, source.url, title.title), + tooltip: tr(context, 'Share source', 'اشتراک منبع'), + icon: const Icon(Icons.share_outlined), + ), + TextButton.icon( + onPressed: source.url.isEmpty + ? null + : () => _play( + context, + title, + source, + episode: episode, + playlist: playlist, + alternatives: visible, + external: true, + ), + label: Text( + tr(context, 'External player', 'پخش‌کننده خارجی'), + ), + icon: const Icon(Icons.open_in_new_rounded), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +Future _play( + BuildContext context, + TitleRecord title, + MediaSource source, { + EpisodeRecord? episode, + List playlist = const [], + List alternatives = const [], + bool external = false, +}) async { + if (!ContentPolicy.allowsTitleJson(title.toJson()) || + !ContentPolicy.allowsUrl(source.url)) { + toast( + context, + tr(context, 'This source is unavailable.', 'این منبع در دسترس نیست.'), + ); + return; + } + final type = source.type.toLowerCase(); + final uri = Uri.tryParse(source.url); + if (type.contains('youtube') || + type.contains('embed') || + uri?.host == 'youtu.be' || + (uri?.host.endsWith('youtube.com') ?? false)) { + try { + if (MediaPlatform.supported) { + if (await MediaPlatform.invoke('openEmbedded', { + 'url': source.url, + 'title': title.title, + }) != + true) { + throw StateError('Unavailable'); + } + } else { + await _openWeb(context, source.url); + } + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not open embedded video.', + 'باز کردن ویدیوی جاسازی‌شده ممکن نشد.', + ), + ); + } + } + return; + } + if (uri == null || + !{'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty) { + toast( + context, + tr( + context, + 'This source URL is unavailable.', + 'پیوند این منبع در دسترس نیست.', + ), + ); + return; + } + if (external || AppScope.read(context).prefs.externalPlayer) { + try { + if (MediaPlatform.supported) { + final state = AppScope.read(context); + final previous = state.history + .where( + (entry) => + entry.title.libraryKey == title.libraryKey && + entry.episodeId == episode?.id, + ) + .firstOrNull; + await MediaPlatform.openExternal( + source.url, + packageName: state.prefs.externalPlayerPackage, + title: title.title, + position: state.prefs.resumePlayback && previous != null + ? Duration( + milliseconds: (previous.positionSeconds * 1000).round(), + ) + : null, + ); + } else { + await _openWeb(context, source.url); + } + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'No external player could open this source.', + 'پخش‌کننده خارجی نتوانست این منبع را باز کند.', + ), + ); + } + } + return; + } + _push( + context, + PlayerPage( + title: title, + source: source, + episode: episode, + playlist: playlist, + alternatives: alternatives, + ), + ); +} + +Future _copy(BuildContext context, String text) async { + if (!ContentPolicy.allowsText(text)) return; + try { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + toast(context, tr(context, 'Copied to clipboard', 'در کلیپ‌بورد کپی شد')); + } + } catch (_) { + if (context.mounted) { + toast( + context, + tr(context, 'Could not copy this link', 'کپی پیوند ممکن نشد'), + ); + } + } +} + +Future _shareTitle(BuildContext context, TitleRecord title) => + _share(context, title.title, title.title); +Future _share(BuildContext context, String text, String title) async { + if (!ContentPolicy.allowsText(text) || !ContentPolicy.allowsText(title)) { + return; + } + final box = context.findRenderObject(); + final origin = box is RenderBox && box.hasSize + ? box.localToGlobal(Offset.zero) & box.size + : null; + try { + await SharePlus.instance.share( + ShareParams( + text: text == title ? title : '$title\n$text', + title: title, + sharePositionOrigin: origin, + ), + ); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Sharing is unavailable on this device.', + 'اشتراک‌گذاری در این دستگاه در دسترس نیست.', + ), + ); + } + } +} + +/// Optional sections load on demand, independently of the primary title request. +class _RemotePanel extends StatefulWidget { + const _RemotePanel({ + required this.title, + required this.load, + required this.builder, + this.initiallyExpanded = false, + }); + final String title; + final Future> Function() load; + final Widget Function(List) builder; + final bool initiallyExpanded; + @override + State<_RemotePanel> createState() => _RemotePanelState(); +} + +class _RemotePanelState extends State<_RemotePanel> { + Future>? _future; + @override + void initState() { + super.initState(); + if (widget.initiallyExpanded) _future = widget.load(); + } + + @override + Widget build(BuildContext context) => ExpansionTile( + initiallyExpanded: widget.initiallyExpanded, + title: Text( + widget.title, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + onExpansionChanged: (expanded) { + if (expanded && _future == null) { + setState(() { + _future = widget.load(); + }); + } + }, + children: [ + if (_future != null) + AsyncResult>( + future: _future!, + onRetry: () => setState(() { + _future = widget.load(); + }), + builder: (items) => items.isEmpty + ? EmptyView( + title: tr( + context, + 'Nothing available here', + 'موردی در دسترس نیست', + ), + ) + : widget.builder(items), + ), + ], + ); +} + +class _SubtitleTile extends StatelessWidget { + const _SubtitleTile({required this.item}); + final Map item; + @override + Widget build(BuildContext context) { + final url = _text(item, ['url', 'link', 'file']); + return ListTile( + leading: const Icon(Icons.subtitles_outlined), + title: Text(_text(item, ['title', 'language', 'name'])), + subtitle: Text( + tr( + context, + 'Open or save with your browser', + 'باز کردن یا ذخیره با مرورگر', + ), + ), + onTap: url.isEmpty ? null : () => _openWeb(context, url), + trailing: IconButton( + onPressed: url.isEmpty ? null : () => _copy(context, url), + tooltip: tr(context, 'Copy subtitle URL', 'کپی پیوند زیرنویس'), + icon: const Icon(Icons.copy), + ), + ); + } +} + +class _RecommendationRail extends StatefulWidget { + const _RecommendationRail({ + super.key, + required this.genre, + this.excludeId = 0, + }); + final int genre, excludeId; + @override + State<_RecommendationRail> createState() => _RecommendationRailState(); +} + +class _RecommendationRailState extends State<_RecommendationRail> { + Future>? _future; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.read(context).api.recommended(widget.genre); + } + + @override + Widget build(BuildContext context) => AsyncResult>( + future: _future!, + onRetry: () => setState(() { + _future = AppScope.read(context).api.recommended(widget.genre); + }), + builder: (items) => items.where((e) => e.id != widget.excludeId).isEmpty + ? const SizedBox.shrink() + : PosterRail( + title: widget.excludeId > 0 + ? tr(context, 'More like this', 'مشابه این عنوان') + : tr( + context, + 'Picked for your taste', + 'پیشنهاد بر اساس سلیقه شما', + ), + items: items.where((e) => e.id != widget.excludeId).toList(), + onMore: () => + _push(context, RecommendationsPage(genre: widget.genre)), + ), + ); +} + +class RecommendationsPage extends StatelessWidget { + const RecommendationsPage({super.key, this.genre = 0, this.byYear = false}); + final int genre; + final bool byYear; + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + genre > 0 + ? tr(context, 'Recommended', 'پیشنهادها') + : byYear + ? tr(context, 'Best by year', 'بهترین‌های هر سال') + : tr(context, 'Surprise me', 'یک پیشنهاد تازه'), + ), + ), + body: _PagedTitles( + load: (page) => genre > 0 + ? AppScope.read(context).api.recommended(genre, page: page) + : AppScope.read(context).api.bestRandom(byYear: byYear, page: page), + ), + ); +} diff --git a/lib/ui/catalog_extras.dart b/lib/ui/catalog_extras.dart new file mode 100644 index 0000000..314c371 --- /dev/null +++ b/lib/ui/catalog_extras.dart @@ -0,0 +1,865 @@ +part of 'catalog.dart'; + +/// Account-free discovery, using the observed routes rather than invented tags. +class DiscoveryDirectoryPage extends StatelessWidget { + const DiscoveryDirectoryPage({super.key}); + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(tr(context, 'Explore more', 'کشف بیشتر'))), + body: ListView( + children: [ + for (final preset in <(String, String, String, int, String)>[ + ('Best titles', 'بهترین عنوان‌ها', 'mixed', 0, 'rating'), + ('Newest titles', 'جدیدترین عنوان‌ها', 'mixed', 0, 'created'), + ('Popular titles', 'عنوان‌های محبوب', 'mixed', 0, 'rating'), + ('Top 250 movies', '۲۵۰ فیلم برتر', 'movie', 0, 'imdb'), + ('Top 250 series', '۲۵۰ سریال برتر', 'serie', 0, 'imdb'), + ('Korean series', 'سریال‌های کره‌ای', 'serie', 36, 'created'), + ( + 'Chinese / Japanese series', + 'سریال‌های چینی / ژاپنی', + 'serie', + 37, + 'created', + ), + ('Turkish series', 'سریال‌های ترکی', 'serie', 35, 'created'), + ('Indian series', 'سریال‌های هندی', 'serie', 38, 'created'), + ('Indian movies', 'فیلم‌های هندی', 'movie', 38, 'created'), + ( + 'Newest movies by year', + 'جدیدترین فیلم‌ها بر اساس سال', + 'movie', + 0, + 'year', + ), + ( + 'Newest series by year', + 'جدیدترین سریال‌ها بر اساس سال', + 'serie', + 0, + 'year', + ), + if (!AppScope.of(context).usesIranCatalogue) ...[ + ('Most-viewed movies', 'پربازدیدترین فیلم‌ها', 'movie', 0, 'views'), + ( + 'Most-viewed series', + 'پربازدیدترین سریال‌ها', + 'serie', + 0, + 'views', + ), + ], + ]) + ListTile( + leading: const Icon(Icons.movie_filter_outlined), + title: Text(tr(context, preset.$1, preset.$2)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _push( + context, + BrowsePage( + initialKind: preset.$3, + initialGenre: preset.$4, + initialOrder: preset.$5, + heading: tr(context, preset.$1, preset.$2), + ), + ), + ), + const Divider(), + for (final entry in <(String, String, Widget)>[ + ( + 'Miniseries', + 'مینی سریال', + const SearchPage(initialQuery: 'مینی سریال'), + ), + ('Sitcoms', 'سیتکام', const SearchPage(initialQuery: 'سیتکام')), + ('Actors', 'بازیگران', const ActorsPage()), + ('Countries', 'کشورها', const CountriesPage()), + ( + 'Themes & subjects', + 'موضوع‌ها', + const SubjectDirectoryPage(kind: DiscoveryDirectory.themes), + ), + ( + 'Franchises', + 'فرنچایزها', + const SubjectDirectoryPage(kind: DiscoveryDirectory.franchises), + ), + ( + 'Most-shared titles', + 'عنوان‌های بیشتر اشتراک‌گذاشته‌شده', + const SharedTitlesPage(), + ), + ( + 'New discoveries', + 'تازه‌های کشف', + const SharedTitlesPage(newest: true), + ), + ('Editor picks', 'پیشنهاد سردبیر', const CuratedPage()), + ( + 'Best collections', + 'مجموعه‌های برتر', + const CuratedPage(best: true), + ), + ( + 'Best by year', + 'بهترین‌های هر سال', + const RecommendationsPage(byYear: true), + ), + ]) + ListTile( + title: Text(tr(context, entry.$1, entry.$2)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _push(context, entry.$3), + ), + ], + ), + ); +} + +class _DiscoveryHomeShelves extends StatefulWidget { + const _DiscoveryHomeShelves(); + @override + State<_DiscoveryHomeShelves> createState() => _DiscoveryHomeShelvesState(); +} + +class _DiscoveryHomeShelvesState extends State<_DiscoveryHomeShelves> { + Future>>? _actors; + Future>? _countries; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final api = AppScope.read(context).api; + _actors ??= api.actors(); + _countries ??= api.countries(); + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 8), + child: Row( + children: [ + Expanded( + child: _heading( + context, + tr(context, 'Popular actors', 'بازیگران محبوب'), + ), + ), + TextButton( + onPressed: () => _push(context, const ActorsPage()), + child: Text(tr(context, 'View all', 'نمایش همه')), + ), + ], + ), + ), + AsyncResult>>( + future: _actors!, + onRetry: () => setState(() { + _actors = AppScope.read(context).api.actors(); + }), + builder: (items) => items.isEmpty + ? const SizedBox.shrink() + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: _PeopleGrid(items: items.take(12).toList()), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 8), + child: Row( + children: [ + Expanded( + child: _heading( + context, + tr(context, 'Discover by country', 'کشف بر اساس کشور'), + ), + ), + TextButton( + onPressed: () => _push(context, const CountriesPage()), + child: Text(tr(context, 'View all', 'نمایش همه')), + ), + ], + ), + ), + AsyncResult>( + future: _countries!, + onRetry: () => setState(() { + _countries = AppScope.read(context).api.countries(); + }), + builder: (items) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final country + in items + .where( + (tag) => + tag.id > 0 && ContentPolicy.allowsText(tag.title), + ) + .take(16)) + ActionChip( + label: Text(country.title), + avatar: const Icon(Icons.public, size: 16), + onPressed: () => _push( + context, + BrowsePage( + initialKind: 'mixed', + initialCountry: country.id, + heading: country.title, + ), + ), + ), + ], + ), + ), + ), + ], + ); +} + +class CountriesPage extends StatefulWidget { + const CountriesPage({super.key}); + @override + State createState() => _CountriesPageState(); +} + +class _CountriesPageState extends State { + Future>? _future; + String _query = ''; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.read(context).api.countries(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(tr(context, 'Countries', 'کشورها'))), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + onChanged: (value) => + setState(() => _query = value.trim().toLowerCase()), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: tr(context, 'Find a country', 'جستجوی کشور'), + ), + ), + ), + Expanded( + child: AsyncResult>( + future: _future!, + onRetry: () => setState(() { + _future = AppScope.read(context).api.countries(); + }), + builder: (items) { + final visible = items + .where( + (tag) => + tag.id > 0 && + ContentPolicy.allowsText(tag.title) && + tag.title.toLowerCase().contains(_query), + ) + .toList(); + return visible.isEmpty + ? EmptyView( + title: tr( + context, + 'No countries found', + 'کشوری پیدا نشد', + ), + ) + : ListView( + children: [ + for (final country in visible) + ListTile( + leading: const Icon(Icons.public), + title: Text(country.title), + trailing: const Icon(Icons.chevron_right), + onTap: () => _push( + context, + BrowsePage( + initialKind: 'mixed', + initialCountry: country.id, + heading: country.title, + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); +} + +class ActorsPage extends StatefulWidget { + const ActorsPage({super.key}); + @override + State createState() => _ActorsPageState(); +} + +class _ActorsPageState extends State { + String query = ''; + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(tr(context, 'Actors', 'بازیگران'))), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + textInputAction: TextInputAction.search, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: tr(context, 'Search actors', 'جستجوی بازیگران'), + ), + onSubmitted: (value) => setState(() => query = value.trim()), + ), + ), + Expanded( + child: _PagedObjects( + key: ValueKey(query), + load: (page) => + AppScope.read(context).api.actors(page: page, search: query), + builder: (items) => _PeopleGrid(items: items), + ), + ), + ], + ), + ); +} + +class _PeopleGrid extends StatelessWidget { + const _PeopleGrid({required this.items}); + final List> items; + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 12, + children: [ + for (final person + in items + .map(actorMetadata) + .where((row) => ContentPolicy.allowsText('${row['name']}'))) + SizedBox( + width: 125, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _push(context, ActorTitlesPage(person: person)), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + ClipOval( + child: SizedBox( + width: 80, + height: 80, + child: _text(person, ['image']).isEmpty + ? const ColoredBox( + color: Color(0xFF252733), + child: Icon(Icons.person_outline, size: 44), + ) + : MediaArtwork(url: _text(person, ['image'])), + ), + ), + const SizedBox(height: 8), + Text( + _text(person, ['name']), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + if (_text(person, ['role']).isNotEmpty) + Text( + ContentPolicy.sanitizeText(_text(person, ['role'])), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ), + ), + ], + ); +} + +class ActorTitlesPage extends StatefulWidget { + const ActorTitlesPage({super.key, required this.person}); + final Map person; + @override + State createState() => _ActorTitlesPageState(); +} + +class _ActorTitlesPageState extends State { + ActorFilmography? _loader; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final person = actorMetadata(widget.person); + _loader ??= ActorFilmography( + CatalogDiscovery(AppScope.read(context).api), + id: _id(person, ['id']), + name: _text(person, ['name']), + ); + } + + @override + Widget build(BuildContext context) { + final person = actorMetadata(widget.person); + return Scaffold( + appBar: AppBar( + title: Text(ContentPolicy.sanitizeText(_text(person, ['name']))), + ), + body: _PagedTitles( + load: _loader!.load, + headerBuilder: () => Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipOval( + child: SizedBox( + width: 86, + height: 86, + child: _text(person, ['image']).isEmpty + ? const Icon(Icons.person_outline, size: 60) + : MediaArtwork(url: _text(person, ['image'])), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final field in [ + ('role', 'Role', 'نقش'), + ('born', 'Born', 'تولد'), + ('height', 'Height', 'قد'), + ]) + if (_text(person, [field.$1]).isNotEmpty) + Text( + '${tr(context, field.$2, field.$3)}: ${ContentPolicy.sanitizeText(_text(person, [field.$1]))}', + ), + ], + ), + ), + ], + ), + if (_text(person, ['bio']).isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: _Synopsis( + text: ContentPolicy.sanitizeText(_text(person, ['bio'])), + ), + ), + if (_loader!.usingNameFallback) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + tr( + context, + 'Filmography unavailable. Showing title search by actor name.', + 'فیلموگرافی موجود نیست. نتایج جستجوی نام بازیگر نمایش داده می‌شود.', + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class SubjectDirectoryPage extends StatelessWidget { + const SubjectDirectoryPage({super.key, required this.kind}); + final DiscoveryDirectory kind; + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + kind == DiscoveryDirectory.themes + ? tr(context, 'Themes & subjects', 'موضوع‌ها') + : tr(context, 'Franchises', 'فرنچایزها'), + ), + ), + body: _PagedObjects( + load: (page) => + CatalogDiscovery(AppScope.read(context).api) + .directory(kind, page: page), + builder: (items) => Column( + children: [ + for (final item in items.where( + (row) => ContentPolicy.allowsText(_text(row, ['title'])), + )) + ListTile( + leading: Icon( + kind == DiscoveryDirectory.themes + ? Icons.topic_outlined + : Icons.collections_bookmark_outlined, + ), + title: Text(_text(item, ['title'])), + subtitle: + kind == DiscoveryDirectory.franchises && + _text(item, ['img']).isNotEmpty + ? Text(ContentPolicy.sanitizeText(_text(item, ['img']))) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => _push( + context, + SearchPage(initialQuery: _text(item, ['title'])), + ), + ), + ], + ), + ), + ); +} + +class SharedTitlesPage extends StatelessWidget { + const SharedTitlesPage({super.key, this.newest = false}); + final bool newest; + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + newest + ? tr(context, 'New discoveries', 'تازه‌های کشف') + : tr( + context, + 'Most-shared titles', + 'عنوان‌های بیشتر اشتراک‌گذاشته‌شده', + ), + ), + ), + body: _PagedTitles( + load: (page) => + CatalogDiscovery(AppScope.read(context).api) + .sharedTitles(page: page, newest: newest), + ), + ); +} + +class CuratedPage extends StatefulWidget { + const CuratedPage({super.key, this.best = false}); + final bool best; + @override + State createState() => _CuratedPageState(); +} + +class _CuratedPageState extends State { + Future>>? future; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + future ??= AppScope.read(context).api.curatedLinks(best: widget.best); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + widget.best + ? tr(context, 'Best collections', 'مجموعه‌های برتر') + : tr(context, 'Editor picks', 'پیشنهاد سردبیر'), + ), + ), + body: AsyncResult>>( + future: future!, + onRetry: () => setState(() { + future = AppScope.read(context).api.curatedLinks(best: widget.best); + }), + builder: (items) { + final visible = items + .where( + (row) => + ContentPolicy.allowsText(_text(row, ['title'])) && + ContentPolicy.allowsUrl(_text(row, ['link'])), + ) + .toList(); + return visible.isEmpty + ? EmptyView( + title: tr( + context, + 'No collections returned', + 'مجموعه‌ای دریافت نشد', + ), + ) + : ListView( + children: [ + for (final item in visible) + ListTile( + leading: const Icon(Icons.auto_awesome_outlined), + title: Text(_text(item, ['title'])), + trailing: const Icon(Icons.chevron_right), + onTap: () => _push( + context, + _CuratedContents( + title: _text(item, ['title']), + link: _text(item, ['link']), + ), + ), + ), + ], + ); + }, + ), + ); +} + +class _CuratedContents extends StatelessWidget { + const _CuratedContents({required this.title, required this.link}); + final String title, link; + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(title)), + body: _PagedTitles( + load: (page) => + AppScope.read(context).api.curatedContent(link, page: page), + ), + ); +} + +class _PagedObjects extends StatefulWidget { + const _PagedObjects({super.key, required this.load, required this.builder}); + final Future>> Function(int) load; + final Widget Function(List>) builder; + @override + State<_PagedObjects> createState() => _PagedObjectsState(); +} + +class _PagedObjectsState extends State<_PagedObjects> { + final items = >[]; + final _scroll = ScrollController(); + int page = 1, _emptyPages = 0; + bool busy = false, end = false; + Object? error; + @override + void initState() { + super.initState(); + _scroll.addListener(_nearEnd); + load(); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + void _nearEnd() { + if (_emptyPages < 4 && + error == null && + _scroll.hasClients && + _scroll.position.extentAfter < 350) { + load(); + } + } + + Future load() async { + if (busy || end) return; + setState(() { + busy = true; + error = null; + }); + try { + final result = await widget.load(page); + if (!mounted) return; + final known = items.map(jsonEncode).toSet(); + final fresh = result.where((row) => known.add(jsonEncode(row))).toList(); + setState(() { + items.addAll(fresh); + end = result is DiscoveryObjectPage + ? result.nextPage == null + : result.isEmpty; + page = result is DiscoveryObjectPage + ? result.nextPage ?? page + : page + 1; + _emptyPages = fresh.isEmpty ? _emptyPages + 1 : 0; + }); + } catch (_) { + if (mounted) { + setState( + () => error = const ApiException('Could not load. Please retry.'), + ); + } + } finally { + if (mounted) { + setState(() => busy = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _nearEnd(); + }); + } + } + } + + Future _refresh() async { + if (busy) return; + setState(() { + items.clear(); + page = 1; + end = false; + error = null; + _emptyPages = 0; + }); + await load(); + } + + @override + Widget build(BuildContext context) => RefreshIndicator( + onRefresh: _refresh, + child: ListView( + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + children: [ + Text( + '${items.length} ${tr(context, 'items loaded', 'مورد بارگذاری شده')}', + ), + widget.builder(items), + if (busy) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + if (error != null) + EmptyView( + title: tr(context, 'Could not load', 'بارگذاری ممکن نشد'), + onRetry: load, + ), + if (!busy && error == null && end && items.isEmpty) + EmptyView( + title: tr( + context, + 'Nothing returned by the service', + 'سرویس موردی برنگرداند', + ), + onRetry: _refresh, + ), + if (!busy && !end && error == null) + OutlinedButton( + onPressed: () { + _emptyPages = 0; + load(); + }, + child: Text(tr(context, 'Load more', 'نمایش بیشتر')), + ), + ], + ), + ); +} + +class _ContinueWatchingShelf extends StatelessWidget { + const _ContinueWatchingShelf(); + @override + Widget build(BuildContext context) { + final known = {}; + final entries = AppScope.of(context).history + .where( + (entry) => + entry.positionSeconds >= 5 && + !entry.title.isChannel && + ContentPolicy.allowsTitleJson(entry.title.toJson()) && + known.add(entry.title.libraryKey), + ) + .take(6) + .toList(); + if (entries.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: _heading( + context, + tr(context, 'Continue watching', 'ادامه تماشا'), + ), + ), + for (final entry in entries) + ListTile( + leading: const Icon(Icons.play_circle_outline), + title: Text(entry.title.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (entry.episodeTitle.isNotEmpty) Text(entry.episodeTitle), + LinearProgressIndicator(value: entry.progress), + Text('${(entry.progress * 100).round()}%'), + ], + ), + onTap: () => _continueCurrentHistory(context, entry), + trailing: IconButton( + tooltip: tr(context, 'Title details', 'جزئیات عنوان'), + icon: const Icon(Icons.info_outline), + onPressed: () => goTitle(context, entry.title), + ), + ), + ], + ); + } +} + +Future _continueCurrentHistory( + BuildContext context, + WatchEntry entry, +) async { + try { + final api = AppScope.read(context).api; + final title = await api.poster(entry.title.id); + if (!ContentPolicy.allowsTitleJson(title.toJson())) { + throw const ApiException('Unavailable'); + } + EpisodeRecord? episode; + var playlist = []; + if (entry.episodeId != null) { + playlist = (await api.seasons(title.id)) + .expand((season) => season.episodes) + .toList(); + episode = playlist + .where((item) => item.id == entry.episodeId) + .firstOrNull; + if (episode == null) throw const ApiException('Episode unavailable'); + } + final sources = (episode?.sources ?? title.sources) + .where((source) => ContentPolicy.allowsUrl(source.url)) + .toList(); + final source = currentHistorySource(sources, entry.sourceUrl); + if (source == null) throw const ApiException('Source unavailable'); + if (context.mounted) { + await _play( + context, + title, + source, + episode: episode, + playlist: playlist, + alternatives: sources, + ); + } + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not refresh the current playback source. Open title details and retry.', + 'به‌روزرسانی منبع پخش ممکن نشد. جزئیات عنوان را باز کنید و دوباره تلاش کنید.', + ), + ); + } + } +} diff --git a/lib/ui/common.dart b/lib/ui/common.dart new file mode 100644 index 0000000..8e86966 --- /dev/null +++ b/lib/ui/common.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; + +import '../data/models.dart'; +import '../data/content_policy.dart'; +import '../state/app_state.dart'; +import 'catalog.dart'; + +enum ToastKind { info, success, error } + +void toast( + BuildContext context, + String message, { + ToastKind kind = ToastKind.info, +}) { + if (!context.mounted) return; + final colors = Theme.of(context).colorScheme; + final text = ContentPolicy.sanitizeText(message); + final background = switch (kind) { + ToastKind.error => colors.errorContainer, + ToastKind.success => colors.tertiaryContainer, + ToastKind.info => colors.inverseSurface, + }; + final foreground = switch (kind) { + ToastKind.error => colors.onErrorContainer, + ToastKind.success => colors.onTertiaryContainer, + ToastKind.info => colors.onInverseSurface, + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: background, + showCloseIcon: true, + closeIconColor: foreground, + content: Row( + children: [ + Icon( + switch (kind) { + ToastKind.error => Icons.error_outline, + ToastKind.success => Icons.check_circle_outline, + ToastKind.info => Icons.info_outline, + }, + color: foreground, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text.isEmpty + ? tr( + context, + 'This content is not available', + 'این محتوا در دسترس نیست', + ) + : text, + style: TextStyle(color: foreground), + ), + ), + ], + ), + ), + ); +} + +void goTitle(BuildContext context, TitleRecord title) { + if (!ContentPolicy.allowsTitleJson(title.toJson()) || + title.id <= 0 || + title.title.isEmpty) { + toast( + context, + tr(context, 'This content is not available', 'این محتوا در دسترس نیست'), + ); + return; + } + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => TitlePage(title: title))); +} + +class EmptyView extends StatelessWidget { + const EmptyView({ + super.key, + required this.title, + this.message, + this.icon = Icons.movie_outlined, + this.onRetry, + }); + final String title; + final String? message; + final IconData icon; + final VoidCallback? onRetry; + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 44, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 18), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 10), + Text( + message!, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + if (onRetry != null) ...[ + const SizedBox(height: 20), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(tr(context, 'Try again', 'تلاش دوباره')), + ), + ], + ], + ), + ), + ); +} + +class AsyncResult extends StatelessWidget { + const AsyncResult({ + super.key, + required this.future, + required this.builder, + this.onRetry, + }); + final Future future; + final Widget Function(T) builder; + final VoidCallback? onRetry; + @override + Widget build(BuildContext context) => FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: Padding( + padding: EdgeInsets.all(48), + child: CircularProgressIndicator(), + ), + ); + } + if (snapshot.hasError) { + return EmptyView( + title: tr( + context, + 'Could not load this page', + 'بارگذاری صفحه ممکن نشد', + ), + message: snapshot.error.toString(), + icon: Icons.wifi_off_rounded, + onRetry: onRetry, + ); + } + if (!snapshot.hasData) { + return EmptyView( + title: tr(context, 'Nothing here yet', 'هنوز محتوایی وجود ندارد'), + ); + } + return builder(snapshot.data as T); + }, + ); +} + +class MediaArtwork extends StatelessWidget { + const MediaArtwork({ + super.key, + required this.url, + this.fit = BoxFit.cover, + this.width, + this.height, + }); + final String url; + final BoxFit fit; + final double? width, height; + @override + Widget build(BuildContext context) { + Widget fallback() => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.local_movies_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 32, + ), + ), + ); + final dataSaver = + context + .dependOnInheritedWidgetOfExactType() + ?.notifier + ?.prefs + .dataSaver ?? + false; + if (url.isEmpty || dataSaver || !ContentPolicy.allowsUrl(url)) { + return SizedBox(width: width, height: height, child: fallback()); + } + return LayoutBuilder( + builder: (context, constraints) { + // A parent SizedBox often supplies thumbnail dimensions instead of the + // optional widget width. Decode for that slot, not the 480px fallback. + final logicalWidth = width ?? + (constraints.hasBoundedWidth ? constraints.maxWidth : null); + final cacheWidth = logicalWidth == null || !logicalWidth.isFinite + ? 480 + : (logicalWidth * MediaQuery.devicePixelRatioOf(context)) + .ceil() + .clamp(1, 1200); + return Image.network( + url, + fit: fit, + width: width, + height: height, + cacheWidth: cacheWidth, + errorBuilder: (_, _, _) => + SizedBox(width: width, height: height, child: fallback()), + loadingBuilder: (_, child, progress) => + progress == null ? child : fallback(), + ); + }, + ); + } +} + +class PosterCard extends StatelessWidget { + const PosterCard({super.key, required this.title, this.width = 132}); + final TitleRecord title; + final double width; + @override + Widget build(BuildContext context) { + if (!ContentPolicy.allowsTitleJson(title.toJson()) || + title.id <= 0 || + title.title.isEmpty) { + return const SizedBox.shrink(); + } + final state = AppScope.of(context); + return SizedBox( + width: width, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => goTitle(context, title), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Stack( + fit: StackFit.expand, + children: [ + MediaArtwork(url: title.image, width: width), + if (title.imdb > 0) + PositionedDirectional( + top: 8, + start: 8, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .78), + borderRadius: BorderRadius.circular(7), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Text( + '★ ${title.imdb.toStringAsFixed(1)}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFFFD777), + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + PositionedDirectional( + bottom: 6, + end: 6, + child: IconButton.filledTonal( + constraints: const BoxConstraints.tightFor( + width: 32, + height: 32, + ), + padding: EdgeInsets.zero, + iconSize: 18, + tooltip: tr(context, 'Watchlist', 'تماشای بعدی'), + onPressed: () async { + try { + await state.toggleSaved(title); + } catch (error) { + if (context.mounted) { + toast( + context, + error.toString(), + kind: ToastKind.error, + ); + } + } + }, + icon: Icon( + state.hasSaved(title.id, kind: title.type) + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 9), + Text( + title.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + [ + if (title.year > 0) '${title.year}', + title.isSeries + ? tr(context, 'Series', 'سریال') + : tr(context, 'Movie', 'فیلم'), + ].join(' · '), + maxLines: 1, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class PosterRail extends StatelessWidget { + const PosterRail({ + super.key, + required this.title, + required this.items, + this.onMore, + }); + final String title; + final List items; + final VoidCallback? onMore; + @override + Widget build(BuildContext context) { + final visible = items + .where( + (item) => + item.id > 0 && + item.title.isNotEmpty && + ContentPolicy.allowsTitleJson(item.toJson()), + ) + .toList(); + if (visible.isEmpty || !ContentPolicy.allowsText(title)) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 12, 12), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + if (onMore != null) + TextButton( + onPressed: onMore, + child: Text(tr(context, 'See all', 'نمایش همه')), + ), + ], + ), + ), + SizedBox( + height: 244, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), + scrollDirection: Axis.horizontal, + itemCount: visible.length, + separatorBuilder: (_, _) => const SizedBox(width: 13), + itemBuilder: (_, index) => PosterCard(title: visible[index]), + ), + ), + ], + ), + ); + } +} + +class PosterGrid extends StatelessWidget { + const PosterGrid({super.key, required this.items, this.sliver = false}); + final List items; + + /// Compose directly into the owning viewport instead of measuring every row. + final bool sliver; + @override + Widget build(BuildContext context) { + final visible = items + .where( + (item) => + item.id > 0 && + item.title.isNotEmpty && + ContentPolicy.allowsTitleJson(item.toJson()), + ) + .toList(); + if (visible.isEmpty) { + return sliver + ? const SliverToBoxAdapter(child: SizedBox.shrink()) + : const SizedBox.shrink(); + } + final state = AppScope.of(context); + final Widget content; + if (state.prefs.compactCatalog) { + content = SliverList.separated( + itemCount: visible.length, + separatorBuilder: (_, _) => const Divider(height: 8), + itemBuilder: (context, index) { + final title = visible[index]; + return ListTile( + key: ValueKey('compact-title-row:${title.libraryKey}'), + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + minTileHeight: 96, + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: MediaArtwork(url: title.image, width: 56, height: 80), + ), + title: Text( + title.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + [ + if (title.year > 0) '${title.year}', + title.isSeries + ? tr(context, 'Series', 'سریال') + : tr(context, 'Movie', 'فیلم'), + if (title.imdb > 0) 'IMDb ${title.imdb.toStringAsFixed(1)}', + ].join(' · '), + ), + onTap: () => goTitle(context, title), + trailing: IconButton( + tooltip: tr(context, 'Watchlist', 'تماشای بعدی'), + icon: Icon( + state.hasSaved(title.id, kind: title.type) + ? Icons.bookmark + : Icons.bookmark_outline, + ), + onPressed: () async { + try { + await state.toggleSaved(title); + } catch (error) { + if (context.mounted) { + toast(context, error.toString(), kind: ToastKind.error); + } + } + }, + ), + ); + }, + ); + } else { + content = SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 190, + childAspectRatio: .56, + mainAxisSpacing: 20, + crossAxisSpacing: 14, + ), + itemCount: visible.length, + itemBuilder: (_, index) => PosterCard(title: visible[index]), + ); + } + final padded = SliverPadding( + padding: const EdgeInsets.all(16), + sliver: content, + ); + return sliver + ? padded + : CustomScrollView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + slivers: [padded], + ); + } +} diff --git a/lib/ui/home_banners.dart b/lib/ui/home_banners.dart new file mode 100644 index 0000000..e10fee6 --- /dev/null +++ b/lib/ui/home_banners.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../data/home_banners.dart'; +import '../state/app_state.dart'; +import 'common.dart'; + +/// Independent of home metadata loading/error UI so the official channel stays +/// visible and usable even before configuration has arrived. +class HomeBanners extends StatelessWidget { + const HomeBanners({super.key, required this.future}); + final Future> future; + + Future _open(BuildContext context, Uri target) async { + try { + if (await launchUrl(target, mode: LaunchMode.externalApplication)) return; + } catch (_) { + // Do not expose platform exceptions, configuration values or URLs. + } + if (context.mounted) { + toast( + context, + tr(context, 'Could not open this link', 'باز کردن پیوند ممکن نشد'), + kind: ToastKind.error, + ); + } + } + + @override + Widget build(BuildContext context) => FutureBuilder>( + future: future, + builder: (context, snapshot) { + final banners = [ + HomeBanner.telegram, + if (!snapshot.hasError) + ...?snapshot.data?.where((item) => item.isAdvertisement).take(5), + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + children: [ + for (var i = 0; i < banners.length; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == banners.length - 1 ? 0 : 12, + ), + child: _BannerCard( + banner: banners[i], + tapKey: Key( + banners[i].isAdvertisement + ? 'home-banner-ad-$i' + : 'home-banner-telegram', + ), + onTap: () => _open(context, banners[i].target), + ), + ), + ], + ), + ); + }, + ); +} + +class _BannerCard extends StatelessWidget { + const _BannerCard({ + required this.banner, + required this.tapKey, + required this.onTap, + }); + final HomeBanner banner; + final Key tapKey; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final persian = state.locale == 'fa'; + final colors = Theme.of(context).colorScheme; + final subtitle = banner.subtitle(persian); + return Material( + color: colors.primary.withValues(alpha: .08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.primary.withValues(alpha: .22)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: tapKey, + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (banner.image != null && !state.prefs.dataSaver) + Image.network( + banner.image.toString(), + height: 112, + fit: BoxFit.cover, + cacheWidth: 700, + excludeFromSemantics: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + banner.isAdvertisement + ? Icons.ad_units_outlined + : Icons.campaign_outlined, + color: colors.primary, + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (banner.isAdvertisement) ...[ + Text( + tr(context, 'Advertisement', 'تبلیغات'), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + ], + Text( + banner.title(persian), + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.open_in_new_rounded, + color: colors.primary, + size: 18, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/library.dart b/lib/ui/library.dart new file mode 100644 index 0000000..a684150 --- /dev/null +++ b/lib/ui/library.dart @@ -0,0 +1,1518 @@ +import 'dart:convert'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../state/cache_service.dart'; +import '../data/content_policy.dart'; + +import '../data/models.dart'; +import '../media/downloads_page.dart'; +import '../media/player_page.dart'; +import '../state/app_state.dart'; +import 'catalog.dart'; +import 'common.dart'; + +void _push(BuildContext context, Widget page) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + +Future choosePreferredGenres(BuildContext context) async { + final state = AppScope.read(context); + final selected = {...state.prefs.preferredGenres}; + final future = state.api.genres(); + final result = await showDialog>( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, update) => AlertDialog( + title: Text(tr(context, 'Your taste', 'سلیقه شما')), + content: SizedBox( + width: 480, + height: 350, + child: AsyncResult>( + future: future, + builder: (genres) => SingleChildScrollView( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: genres + .where((g) => g.id > 0 && ContentPolicy.allowsText(g.title)) + .map( + (genre) => FilterChip( + label: Text(genre.title), + selected: selected.contains(genre.id), + onSelected: (value) => update(() { + value + ? selected.add(genre.id) + : selected.remove(genre.id); + }), + ), + ) + .toList(), + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(tr(context, 'Cancel', 'انصراف')), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, selected.toList()), + child: Text(tr(context, 'Save', 'ذخیره')), + ), + ], + ), + ), + ); + if (result != null) { + await state.updateSettings(state.prefs.copyWith(preferredGenres: result)); + } +} + +class LibraryPage extends StatelessWidget { + const LibraryPage({super.key}); + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: Text(tr(context, 'Your library', 'کتابخانه شما')), + actions: [ + IconButton( + tooltip: tr(context, 'Downloads', 'دانلودها'), + onPressed: () => _push(context, const DownloadsPage()), + icon: const Icon(Icons.download_for_offline_outlined), + ), + ], + bottom: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + labelPadding: const EdgeInsets.symmetric(horizontal: 12), + tabs: [ + Tab( + text: + '${tr(context, 'Watchlist', 'تماشای بعدی')} (${state.savedCount})', + ), + Tab( + text: + '${tr(context, 'Continue', 'ادامه تماشا')} (${state.historyCount})', + ), + Tab( + text: + '${tr(context, 'Collections', 'مجموعه‌ها')} (${state.collectionCount})', + ), + ], + ), + ), + body: TabBarView( + children: [ + state.saved.isEmpty + ? EmptyView( + title: tr( + context, + 'A little inspiration, saved', + 'فیلم‌های مورد علاقه را ذخیره کنید', + ), + message: tr( + context, + 'Tap the bookmark on any title to keep it here.', + 'برای ذخیره یک عنوان، نشانک آن را بزنید.', + ), + icon: Icons.bookmark_border_rounded, + ) + : SingleChildScrollView(child: PosterGrid(items: state.saved)), + state.history.isEmpty + ? EmptyView( + title: tr( + context, + 'Pick up where you left off', + 'تماشا را از جایی که بودید ادامه دهید', + ), + message: tr( + context, + 'Your playback progress stays on this device.', + 'سابقه پخش فقط در همین دستگاه ذخیره می‌شود.', + ), + icon: Icons.history_rounded, + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: state.history.length, + itemBuilder: (_, index) { + final item = state.history[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () => goTitle(context, item.title), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 62, + height: 88, + child: MediaArtwork(url: item.title.image), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item.title.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + if (item.episodeTitle.isNotEmpty) + Text( + item.episodeTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: item.progress.clamp(0, 1), + ), + const SizedBox(height: 6), + Text( + '${Duration(seconds: item.positionSeconds.round()).toString().split('.').first} / ${Duration(seconds: item.durationSeconds.round()).toString().split('.').first}', + style: Theme.of(context) + .textTheme + .labelSmall, + ), + ], + ), + ), + Column( + children: [ + IconButton( + tooltip: tr(context, 'Resume', 'ادامه'), + icon: const Icon( + Icons.play_circle_outline_rounded, + ), + onPressed: () async { + try { + final current = await state + .resolveHistory(item); + if (!context.mounted) return; + _push( + context, + PlayerPage( + title: current.title, + source: current.source, + episode: current.episode, + playlist: current.playlist, + alternatives: + current.alternatives, + ), + ); + } catch (_) { + if (!context.mounted) return; + toast( + context, + tr( + context, + 'Could not refresh this source. Choose a current source on the title page.', + 'منبع تازه در دسترس نیست؛ از صفحه عنوان منبع دیگری انتخاب کنید.', + ), + ); + goTitle(context, item.title); + } + }, + ), + IconButton( + tooltip: tr( + context, + 'Remove history', + 'حذف سابقه', + ), + icon: const Icon(Icons.close, size: 18), + onPressed: () => state.removeHistory( + item.title.id, + kind: item.title.type, + episodeId: item.episodeId, + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FilledButton.tonalIcon( + icon: const Icon(Icons.add), + label: Text(tr(context, 'New collection', 'مجموعه جدید')), + onPressed: () async { + final controller = TextEditingController(); + final name = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text( + tr(context, 'Name your collection', 'نام مجموعه'), + ), + content: TextField( + controller: controller, + autofocus: true, + maxLength: 80, + decoration: InputDecoration( + labelText: tr(context, 'Name', 'نام'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(tr(context, 'Cancel', 'انصراف')), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogContext, + controller.text.trim(), + ), + child: Text(tr(context, 'Create', 'ایجاد')), + ), + ], + ), + ); + controller.dispose(); + if (name != null && name.isNotEmpty) { + try { + await state.createCollection(name); + } catch (error) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Operation could not be completed. Please try again.', + 'انجام عملیات ممکن نشد؛ دوباره تلاش کنید.', + ), + ); + } + } + } + }, + ), + ), + ), + Expanded( + child: state.collections.isEmpty + ? EmptyView( + title: tr( + context, + 'Make it your own', + 'مجموعه خودتان را بسازید', + ), + message: tr( + context, + 'Organize titles into collections from their detail pages.', + 'عنوان‌ها را در صفحه جزئیات به مجموعه اضافه کنید.', + ), + icon: Icons.collections_bookmark_outlined, + ) + : ListView.builder( + itemCount: state.collections.length, + itemBuilder: (_, index) { + final collection = state.collections[index]; + return ListTile( + leading: const Icon(Icons.video_library_outlined), + title: Text(collection.name), + subtitle: Text( + '${collection.titles.length} ${tr(context, 'titles', 'عنوان')}', + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: tr( + context, + 'Delete collection', + 'حذف مجموعه', + ), + onPressed: () async { + if (await _confirm( + context, + tr( + context, + 'Delete this collection?', + 'این مجموعه حذف شود؟', + ), + )) { + await state.deleteCollection(collection.id); + } + }, + ), + onTap: () => _push( + context, + _CollectionPage(id: collection.id), + ), + ); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _CollectionPage extends StatelessWidget { + const _CollectionPage({required this.id}); + final String id; + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final matches = state.collections.where((c) => c.id == id); + if (matches.isEmpty) { + return Scaffold( + appBar: AppBar(), + body: EmptyView( + title: tr(context, 'Collection removed', 'مجموعه حذف شده است'), + ), + ); + } + final collection = matches.first; + return Scaffold( + appBar: AppBar(title: Text(collection.name)), + body: collection.titles.isEmpty + ? EmptyView( + title: tr(context, 'No titles yet', 'هنوز عنوانی اضافه نشده'), + ) + : GridView.builder( + padding: const EdgeInsets.all(16), + itemCount: collection.titles.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 190, + childAspectRatio: .56, + mainAxisSpacing: 20, + crossAxisSpacing: 14, + ), + itemBuilder: (_, index) { + final title = collection.titles[index]; + return Stack( + children: [ + Positioned.fill(child: PosterCard(title: title)), + PositionedDirectional( + top: 4, + end: 4, + child: IconButton.filledTonal( + tooltip: tr( + context, + 'Remove from collection', + 'حذف از مجموعه', + ), + icon: const Icon(Icons.close, size: 18), + onPressed: () => state.toggleCollectionTitle(id, title), + ), + ), + ], + ); + }, + ), + ); + } +} + +Future _confirm(BuildContext context, String title) async => + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(tr(context, 'Cancel', 'انصراف')), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(tr(context, 'Confirm', 'تأیید')), + ), + ], + ), + ) ?? + false; + +class MorePage extends StatelessWidget { + const MorePage({super.key}); + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(tr(context, 'More', 'بیشتر'))), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Text( + tr( + context, + 'Your library stays on this device', + 'کتابخانه شما روی همین دستگاه می‌ماند', + ), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + _link( + context, + Icons.download_rounded, + 'Downloads', + 'دانلودها', + const DownloadsPage(), + ), + _link( + context, + Icons.animation_rounded, + 'Anime & animation', + 'انیمه و انیمیشن', + const BrowsePage(initialKind: 'poster', initialGenre: 3), + ), + _link( + context, + Icons.people_outline_rounded, + 'Actors', + 'بازیگران', + const ActorsPage(), + ), + _link( + context, + Icons.recommend_outlined, + 'Curated recommendations', + 'پیشنهادهای سردبیر', + const CuratedPage(), + ), + _link( + context, + Icons.workspace_premium_outlined, + 'Best collections', + 'مجموعه‌های برتر', + const CuratedPage(best: true), + ), + ListTile( + leading: const Icon(Icons.favorite_border), + title: Text(tr(context, 'Preferred genres', 'ژانرهای مورد علاقه')), + subtitle: Text( + tr( + context, + 'Choose genres you enjoy', + 'ژانرهای مورد علاقه را انتخاب کنید', + ), + ), + onTap: () => choosePreferredGenres(context), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Divider(), + ), + _link( + context, + Icons.tune_rounded, + 'Settings', + 'تنظیمات', + const SettingsPage(), + ), + _link( + context, + Icons.privacy_tip_outlined, + 'Privacy & your data', + 'حریم خصوصی و اطلاعات شما', + const _PrivacyPage(), + ), + ListTile( + key: const Key('share-reelbox'), + leading: const Icon(Icons.share_outlined), + title: Text(tr(context, 'Share Reelbox', 'اشتراک‌گذاری Reelbox')), + onTap: () => _shareApp(context), + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(tr(context, 'About Reelbox', 'درباره ریل‌باکس')), + onTap: () => _push(context, const _AboutReelboxPage()), + ), + ], + ), + ); + Future _shareApp(BuildContext context) async { + final box = context.findRenderObject(); + final origin = box is RenderBox && box.hasSize + ? box.localToGlobal(Offset.zero) & box.size + : null; + try { + final result = await SharePlus.instance.share( + ShareParams( + title: 'Reelbox', + text: tr( + context, + 'Reelbox — an account-free film and series client with a private, device-local library', + 'Reelbox — فیلم و سریال، با کتابخانه خصوصی روی دستگاه و بدون نیاز به حساب کاربری', + ), + sharePositionOrigin: origin, + ), + ); + if (result.status == ShareResultStatus.unavailable) { + throw const FormatException('Sharing unavailable'); + } + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Sharing is unavailable on this device', + 'اشتراک‌گذاری در این دستگاه در دسترس نیست', + ), + kind: ToastKind.error, + ); + } + } + } + + Widget _link( + BuildContext context, + IconData icon, + String en, + String fa, + Widget page, + ) => ListTile( + leading: Icon(icon), + title: Text(tr(context, en, fa)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _push(context, page), + ); +} + +/// Explains current client behavior, not an external service's privacy policy. +class _PrivacyPage extends StatelessWidget { + const _PrivacyPage(); + + @override + Widget build(BuildContext context) { + Widget section(String en, String fa, String bodyEn, String bodyFa) => + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr(context, en, fa), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text(tr(context, bodyEn, bodyFa)), + ], + ), + ); + return Scaffold( + appBar: AppBar( + title: Text( + tr(context, 'Privacy & your data', 'حریم خصوصی و اطلاعات شما'), + ), + ), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + section( + 'On this device', + 'روی این دستگاه', + 'No account needed. Watchlists, collections, progress, recent searches and settings stay on this device. Downloads are stored separately.', + 'نیازی به حساب کاربری نیست. فهرست تماشا، مجموعه‌ها، پیشرفت پخش، جستجوهای اخیر و تنظیمات روی همین دستگاه می‌مانند. دانلودها جداگانه ذخیره می‌شوند.', + ), + section( + 'External services', + 'سرویس‌های خارجی', + 'Browsing, searches, artwork, playback and downloads contact external catalog or media providers. They can receive your IP address and requested searches or media; their privacy policies apply.', + 'مرور، جستجو، تصاویر، پخش و دانلود به ارائه‌دهندگان خارجی فهرست یا رسانه متصل می‌شوند. آن‌ها می‌توانند نشانی IP و جستجوها یا رسانه‌های درخواستی شما را دریافت کنند؛ سیاست حریم خصوصی خودشان اعمال می‌شود.', + ), + section( + 'Sharing & backups', + 'اشتراک‌گذاری و پشتیبان‌ها', + 'Sharing sends a link to the app you choose. Library backups include history, settings and source URLs that may contain access tokens. Keep backups private; exported or shared copies are outside Reelbox’s control.', + 'اشتراک‌گذاری، پیوند را به برنامه انتخابی شما می‌فرستد. پشتیبان کتابخانه شامل سابقه، تنظیمات و نشانی منابع است که ممکن است توکن دسترسی داشته باشند. پشتیبان‌ها را خصوصی نگه دارید؛ نسخه‌های خروجی یا اشتراک‌گذاشته‌شده خارج از کنترل ریل‌باکس هستند.', + ), + section( + 'Deleting local data', + 'حذف اطلاعات محلی', + 'Clear cache or reset subtitle style in Settings without deleting your library or completed downloads. Manage downloaded files in Downloads and exported backups separately.', + 'پاک کردن حافظه موقت یا بازنشانی ظاهر زیرنویس در تنظیمات، کتابخانه و دانلودهای کامل را حذف نمی‌کند. فایل‌های دانلودشده را در دانلودها و پشتیبان‌های خروجی را جداگانه مدیریت کنید.', + ), + section( + 'Unencrypted media', + 'رسانه بدون رمزنگاری', + 'Catalog connections use HTTPS. Some artwork or media may use unencrypted HTTP, which networks can observe or modify. Prefer HTTPS when available.', + 'اتصال به فهرست با HTTPS است. برخی تصاویر یا رسانه‌ها ممکن است از HTTP بدون رمزنگاری استفاده کنند که شبکه‌ها می‌توانند آن را ببینند یا تغییر دهند. در صورت دسترسی، HTTPS را ترجیح دهید.', + ), + ], + ), + ); + } +} + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final p = state.prefs; + void save(AppSettings settings) { + state.updateSettings(settings).catchError((Object error) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Operation could not be completed. Please try again.', + 'انجام عملیات ممکن نشد؛ دوباره تلاش کنید.', + ), + ); + } + }); + } + + Widget header(String en, String fa) => Padding( + padding: const EdgeInsetsDirectional.fromSTEB(20, 28, 20, 8), + child: Text( + tr(context, en, fa), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + Widget select( + String en, + String fa, + T value, + Map choices, + void Function(T) changed, + ) => ListTile( + title: Text(tr(context, en, fa)), + trailing: SizedBox( + width: MediaQuery.sizeOf(context).width * .42, + child: DropdownButton( + value: value, + isExpanded: true, + underline: const SizedBox.shrink(), + items: + { + ...choices, + if (!choices.containsKey(value)) + value: '${tr(context, 'Custom', 'سفارشی')}: $value', + }.entries + .map( + (e) => DropdownMenuItem( + value: e.key, + child: Text( + e.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) changed(v); + }, + ), + ), + ); + Widget toggle( + String en, + String fa, + bool value, + void Function(bool) changed, + ) => SwitchListTile( + title: Text(tr(context, en, fa)), + value: value, + onChanged: changed, + ); + return Scaffold( + appBar: AppBar(title: Text(tr(context, 'Settings', 'تنظیمات'))), + body: ListView( + children: [ + header('Appearance', 'ظاهر'), + select('Language', 'زبان', p.locale, { + 'en': 'English', + 'fa': 'فارسی', + }, (v) => save(p.copyWith(locale: v))), + select('Theme', 'پوسته', p.theme, { + 'dark': tr(context, 'Dark', 'تیره'), + 'light': tr(context, 'Light', 'روشن'), + 'system': tr(context, 'System', 'سیستم'), + }, (v) => save(p.copyWith(theme: v))), + toggle( + 'Data saver artwork', + 'کاهش مصرف داده تصاویر', + p.dataSaver, + (v) => save(p.copyWith(dataSaver: v)), + ), + toggle( + 'Compact catalog', + 'فهرست فشرده', + p.compactCatalog, + (v) => save(p.copyWith(compactCatalog: v)), + ), + header('Playback', 'پخش'), + select('Video fit', 'اندازه تصویر', p.videoFit, { + 'contain': tr(context, 'Fit', 'متناسب'), + 'cover': tr(context, 'Crop to fill', 'برش و پرکردن'), + 'fill': tr(context, 'Stretch', 'کشیده'), + }, (v) => save(p.copyWith(videoFit: v))), + toggle( + 'Audio only', + 'فقط صدا', + p.audioOnly, + (v) => save(p.copyWith(audioOnly: v)), + ), + toggle( + 'Background audio', + 'صدا در پس‌زمینه', + p.backgroundAudio, + (v) => save(p.copyWith(backgroundAudio: v)), + ), + toggle( + 'Close player on completion', + 'بستن پخش پس از پایان', + p.closeOnCompletion, + (v) => save(p.copyWith(closeOnCompletion: v)), + ), + select('Resume rewind', 'بازگشت هنگام ادامه', p.resumeRewindSeconds, { + 0: '0s', + 5: '5s', + 10: '10s', + 15: '15s', + 30: '30s', + 60: '60s', + 120: '120s', + }, (v) => save(p.copyWith(resumeRewindSeconds: v))), + toggle( + 'Remember volume', + 'ذخیره بلندی صدا', + p.rememberVolume, + (v) => save(p.copyWith(rememberVolume: v)), + ), + toggle( + 'Resume from last position', + 'ادامه از آخرین موقعیت', + p.resumePlayback, + (v) => save(p.copyWith(resumePlayback: v)), + ), + toggle( + 'Automatically play next episode', + 'پخش خودکار قسمت بعد', + p.autoNext, + (v) => save(p.copyWith(autoNext: v)), + ), + toggle( + 'Autoplay selected source', + 'پخش خودکار منبع انتخاب‌شده', + p.autoplay, + (v) => save(p.copyWith(autoplay: v)), + ), + toggle( + 'Prefer an external player', + 'ترجیح پخش‌کننده خارجی', + p.externalPlayer, + (v) => save(p.copyWith(externalPlayer: v)), + ), + toggle( + 'Hide trailer sources', + 'پنهان کردن تیزرها', + p.hideTrailers, + (v) => save(p.copyWith(hideTrailers: v)), + ), + select('Default quality', 'کیفیت پیش‌فرض', p.defaultQuality, { + 'ask': tr(context, 'Ask each time', 'هر بار بپرس'), + 'auto': tr(context, 'Automatic', 'خودکار'), + '720': '720p', + '1080': '1080p', + }, (v) => save(p.copyWith(defaultQuality: v))), + select('Playback speed', 'سرعت پخش', p.playbackSpeed, { + 0.5: '0.5×', + 0.75: '0.75×', + 1.0: '1×', + 1.25: '1.25×', + 1.5: '1.5×', + 2.0: '2×', + }, (v) => save(p.copyWith(playbackSpeed: v))), + select('Seek step', 'مقدار جابه‌جایی', p.skipSeconds, { + 5: '5s', + 10: '10s', + 15: '15s', + 30: '30s', + }, (v) => save(p.copyWith(skipSeconds: v))), + select('Player orientation', 'جهت صفحه پخش', p.orientation, { + 'auto': tr(context, 'Automatic', 'خودکار'), + 'landscape': tr(context, 'Landscape', 'افقی'), + 'portrait': tr(context, 'Portrait', 'عمودی'), + }, (v) => save(p.copyWith(orientation: v))), + select( + 'External player app', + 'برنامه پخش خارجی', + p.externalPlayerPackage, + { + 'ask': tr(context, 'Ask each time', 'هر بار بپرس'), + 'org.videolan.vlc': 'VLC', + 'com.mxtech.videoplayer.ad': 'MX Player', + 'com.mxtech.videoplayer.pro': 'MX Player Pro', + 'com.brouken.player': 'Just Player', + }, + (v) => save(p.copyWith(externalPlayerPackage: v)), + ), + header('Subtitles & audio', 'زیرنویس و صدا'), + toggle( + 'Enable subtitles', + 'نمایش زیرنویس', + p.subtitleEnabled, + (v) => save(p.copyWith(subtitleEnabled: v)), + ), + select('Subtitle font', 'قلم زیرنویس', p.subtitleFontFamily, { + 'sans-serif': tr(context, 'System sans serif', 'قلم ساده سیستم'), + 'serif': tr(context, 'System serif', 'قلم سریف سیستم'), + 'monospace': tr(context, 'Monospace', 'قلم تک‌عرض'), + }, (v) => save(p.copyWith(subtitleFontFamily: v))), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + tr( + context, + 'Subtitle preview · The next chapter', + 'پیش‌نمایش زیرنویس · فصل بعدی', + ), + key: const Key('subtitle-preview'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: p.resolvedSubtitleFontFamily, + fontFamilyFallback: p.subtitleFontFamilyFallback, + fontSize: p.subtitleFontSize, + color: Color(p.subtitleColor), + backgroundColor: Color(p.subtitleBackground), + ), + ), + ), + ListTile( + title: Text(tr(context, 'Subtitle size', 'اندازه زیرنویس')), + subtitle: Slider( + value: p.subtitleFontSize, + min: 8, + max: 96, + divisions: 88, + label: '${p.subtitleFontSize.round()}', + onChanged: (v) => save(p.copyWith(subtitleFontSize: v)), + ), + ), + select('Subtitle color', 'رنگ زیرنویس', p.subtitleColor, { + 0xFFFFFFFF: tr(context, 'White', 'سفید'), + 0xFFFFFF00: tr(context, 'Yellow', 'زرد'), + 0xFF00FFFF: tr(context, 'Cyan', 'فیروزه‌ای'), + }, (v) => save(p.copyWith(subtitleColor: v))), + select( + 'Subtitle background', + 'پس‌زمینه زیرنویس', + p.subtitleBackground, + { + 0x80000000: tr(context, 'Translucent', 'نیمه‌شفاف'), + 0x00000000: tr(context, 'None', 'بدون پس‌زمینه'), + 0xFF000000: tr(context, 'Black', 'مشکی'), + }, + (v) => save(p.copyWith(subtitleBackground: v)), + ), + _ColorTile( + label: tr(context, 'Custom subtitle color', 'رنگ سفارشی زیرنویس'), + value: p.subtitleColor, + onChanged: (v) => save(p.copyWith(subtitleColor: v)), + ), + _ColorTile( + label: tr( + context, + 'Custom background color', + 'رنگ سفارشی پس‌زمینه', + ), + value: p.subtitleBackground, + onChanged: (v) => save(p.copyWith(subtitleBackground: v)), + ), + ListTile( + key: const Key('reset-subtitle-style'), + leading: const Icon(Icons.format_clear), + title: Text( + tr( + context, + 'Reset subtitle style only', + 'بازنشانی فقط تنظیمات زیرنویس', + ), + ), + onTap: () async { + try { + await state.resetSubtitleStyle(); + } catch (error) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Operation could not be completed. Please try again.', + 'انجام عملیات ممکن نشد؛ دوباره تلاش کنید.', + ), + ); + } + } + }, + ), + _DelayTile( + label: tr( + context, + 'Subtitle delay (seconds)', + 'تأخیر زیرنویس (ثانیه)', + ), + value: p.subtitleDelay, + onChanged: (v) => save(p.copyWith(subtitleDelay: v)), + ), + _DelayTile( + label: tr(context, 'Audio delay (seconds)', 'تأخیر صدا (ثانیه)'), + value: p.audioDelay, + onChanged: (v) => save(p.copyWith(audioDelay: v)), + ), + header('Downloads', 'دانلودها'), + toggle( + 'Download on Wi-Fi only', + 'دانلود فقط با وای‌فای', + p.wifiOnlyDownloads, + (v) => save(p.copyWith(wifiOnlyDownloads: v)), + ), + SwitchListTile( + title: Text( + tr( + context, + 'Notifications for new downloads', + 'اعلان‌های دانلودهای جدید', + ), + ), + subtitle: Text( + tr( + context, + 'Running downloads keep their notification settings. Android may still require a foreground notification.', + 'دانلودهای در حال اجرا تنظیمات اعلان قبلی را حفظ می‌کنند. ممکن است اندروید همچنان اعلان سرویس پیش‌زمینه را الزامی کند.', + ), + ), + value: p.downloadNotifications, + onChanged: (v) => save(p.copyWith(downloadNotifications: v)), + ), + header('Your data', 'اطلاعات شما'), + ListTile( + leading: const Icon(Icons.upload_file_outlined), + title: Text( + tr(context, 'Export local library', 'خروجی کتابخانه محلی'), + ), + onTap: () async { + try { + final uri = await FilePicker.saveFile( + fileName: 'reelbox-library.json', + mimeType: 'application/json', + bytes: Uint8List.fromList(utf8.encode(state.exportLibrary())), + ); + if (uri != null && context.mounted) { + toast( + context, + tr(context, 'Library exported', 'خروجی ذخیره شد'), + ); + } + } catch (error) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Operation could not be completed. Please try again.', + 'انجام عملیات ممکن نشد؛ دوباره تلاش کنید.', + ), + ); + } + } + }, + ), + ListTile( + leading: const Icon(Icons.file_download_outlined), + title: Text( + tr(context, 'Import local library', 'ورود کتابخانه محلی'), + ), + onTap: () async { + try { + final file = await FilePicker.pickFile( + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (file == null) return; + final size = await file.length(); + if (size == null || size > 8 * 1024 * 1024) { + throw const FormatException( + 'Library file must be smaller than 8 MB.', + ); + } + final text = utf8.decode(await file.readAsBytes()); + if (!context.mounted) return; + if (await _confirm( + context, + tr( + context, + 'Replace your local library with this backup?', + 'کتابخانه محلی با این پشتیبان جایگزین شود؟', + ), + )) { + await state.importLibrary(text); + if (context.mounted) { + toast( + context, + tr(context, 'Library imported', 'کتابخانه وارد شد'), + ); + } + } + } catch (error) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Operation could not be completed. Please try again.', + 'انجام عملیات ممکن نشد؛ دوباره تلاش کنید.', + ), + ); + } + } + }, + ), + const _CacheTile(), + const SizedBox(height: 30), + ], + ), + ); + } +} + +/// A network-free introduction. The root app switches to its shell when the +/// persisted introCompleted preference changes; this widget does not push routes. +class FirstRunIntroPage extends StatefulWidget { + const FirstRunIntroPage({super.key}); + @override + State createState() => _FirstRunIntroPageState(); +} + +class _FirstRunIntroPageState extends State { + bool _busy = false; + Future _finish() async { + if (_busy) return; + setState(() => _busy = true); + final state = AppScope.read(context); + try { + await state.updateSettings(state.prefs.copyWith(introCompleted: true)); + } catch (_) { + if (mounted) { + toast( + context, + tr( + context, + 'Could not save preferences. Try again.', + 'ذخیره تنظیمات انجام نشد؛ دوباره تلاش کنید.', + ), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Reelbox'), + actions: [ + TextButton( + key: const Key('intro-skip'), + onPressed: _busy ? null : _finish, + child: Text(tr(context, 'Skip', 'رد شدن')), + ), + ], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Icon(Icons.movie_filter_outlined, size: 80), + const SizedBox(height: 24), + Text( + tr( + context, + 'Your library, your device', + 'کتابخانه شما، دستگاه شما', + ), + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + tr( + context, + 'Save films and series, resume watching and download for offline viewing.', + 'فیلم‌ها و سریال‌ها را ذخیره کنید، تماشا را ادامه دهید و برای تماشای آفلاین دانلود کنید.', + ), + ), + const SizedBox(height: 16), + Text( + tr( + context, + 'No account needed. Your library, history and settings stay on this device. Browsing and playback contact external catalog and media providers.', + 'نیازی به حساب کاربری نیست. کتابخانه، سابقه و تنظیمات روی همین دستگاه می‌مانند. مرور و پخش به ارائه‌دهندگان خارجی فهرست و رسانه متصل می‌شوند.', + ), + ), + const SizedBox(height: 24), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'en', label: Text('English')), + ButtonSegment(value: 'fa', label: Text('فارسی')), + ], + selected: {state.locale}, + onSelectionChanged: _busy + ? null + : (values) async { + try { + await state.updateSettings( + state.prefs.copyWith(locale: values.single), + ); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Could not save language', + 'ذخیره زبان انجام نشد', + ), + ); + } + } + }, + ), + const SizedBox(height: 24), + FilledButton.icon( + key: const Key('intro-complete'), + onPressed: _busy ? null : _finish, + icon: const Icon(Icons.arrow_forward), + label: Text(tr(context, 'Start watching', 'شروع تماشا')), + ), + ], + ), + ), + ); + } +} + +class _AboutReelboxPage extends StatefulWidget { + const _AboutReelboxPage(); + @override + State<_AboutReelboxPage> createState() => _AboutReelboxPageState(); +} + +class _AboutReelboxPageState extends State<_AboutReelboxPage> { + late final Future _package = PackageInfo.fromPlatform(); + @override + Widget build(BuildContext context) { + Widget info(String en, String fa, String value) => ListTile( + title: Text(tr(context, en, fa)), + subtitle: SelectableText(value), + ); + return Scaffold( + appBar: AppBar( + title: Text(tr(context, 'About Reelbox', 'درباره ریل‌باکس')), + ), + body: ListView( + children: [ + FutureBuilder( + future: _package, + builder: (context, snapshot) { + final package = snapshot.data; + if (package == null) { + return ListTile( + title: const Text('Reelbox'), + subtitle: Text( + snapshot.hasError + ? tr( + context, + 'Package information unavailable', + 'اطلاعات بسته در دسترس نیست', + ) + : tr(context, 'Loading version…', 'دریافت نسخه…'), + ), + ); + } + return info( + 'Application', + 'برنامه', + '${package.appName}\n${package.version} (${package.buildNumber})\n${package.packageName}', + ); + }, + ), + Padding( + padding: const EdgeInsets.all(20), + child: Text( + tr( + context, + 'An independent film and series client. Media availability depends on external providers.', + 'برنامه‌ای مستقل برای فیلم و سریال؛ دسترسی به رسانه به ارائه‌دهندگان خارجی بستگی دارد.', + ), + ), + ), + ListTile( + leading: const Icon(Icons.description_outlined), + title: Text(tr(context, 'Open-source licenses', 'مجوزهای متن‌باز')), + onTap: () => + showLicensePage(context: context, applicationName: 'Reelbox'), + ), + ], + ), + ); + } +} + +class _CacheTile extends StatefulWidget { + const _CacheTile(); + @override + State<_CacheTile> createState() => _CacheTileState(); +} + +class _CacheTileState extends State<_CacheTile> { + final CacheService _service = CacheService(); + late Future _size = _service.measure(); + bool _busy = false; + @override + Widget build(BuildContext context) => FutureBuilder( + future: _size, + builder: (context, snapshot) => ListTile( + leading: const Icon(Icons.cleaning_services_outlined), + title: Text(tr(context, 'Clear disposable cache', 'پاک کردن حافظه موقت')), + subtitle: Text( + snapshot.hasData + ? '${(snapshot.data!.totalBytes / (1024 * 1024)).toStringAsFixed(2)} MB · ${tr(context, 'Cached artwork + disposable files only. Completed downloads and library are kept.', 'فقط تصاویر و فایل‌های موقت؛ دانلودهای کامل و کتابخانه حفظ می‌شوند.')}' + : snapshot.hasError + ? tr( + context, + 'Cache size unavailable', + 'اندازه حافظه موقت در دسترس نیست', + ) + : tr(context, 'Measuring cache…', 'محاسبه حافظه موقت…'), + ), + enabled: !_busy, + onTap: () async { + setState(() => _busy = true); + try { + await _service.clear(); + if (!context.mounted) return; + toast( + context, + tr( + context, + 'Disposable cache cleared. Library and downloads kept.', + 'حافظه موقت پاک شد؛ کتابخانه و دانلودها حفظ شدند.', + ), + ); + } catch (_) { + if (context.mounted) { + toast( + context, + tr( + context, + 'Some cache files could not be cleared.', + 'پاک کردن بعضی فایل‌های موقت انجام نشد.', + ), + ); + } + } finally { + if (mounted) { + setState(() { + _busy = false; + _size = _service.measure(); + }); + } + } + }, + ), + ); +} + +class _ColorTile extends StatelessWidget { + const _ColorTile({ + required this.label, + required this.value, + required this.onChanged, + }); + final String label; + final int value; + final ValueChanged onChanged; + @override + Widget build(BuildContext context) => ListTile( + title: Text(label), + subtitle: Text('#${value.toRadixString(16).padLeft(8, '0').toUpperCase()}'), + trailing: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Color(value), + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + ), + onTap: () async { + final chosen = await showDialog( + context: context, + builder: (_) => _ColorDialog(label: label, value: value), + ); + if (chosen != null) onChanged(chosen); + }, + ); +} + +class _ColorDialog extends StatefulWidget { + const _ColorDialog({required this.label, required this.value}); + final String label; + final int value; + @override + State<_ColorDialog> createState() => _ColorDialogState(); +} + +class _ColorDialogState extends State<_ColorDialog> { + late int _value = widget.value; + late final TextEditingController _text = TextEditingController( + text: _hex(_value), + ); + bool _valid = true; + String _hex(int value) => + value.toRadixString(16).padLeft(8, '0').toUpperCase(); + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(widget.label), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 48, width: double.infinity, color: Color(_value)), + const SizedBox(height: 16), + TextField( + key: const Key('custom-color-hex'), + controller: _text, + decoration: InputDecoration( + labelText: 'AARRGGBB / RRGGBB', + prefixText: '#', + errorText: _valid + ? null + : tr( + context, + 'Enter 6 or 8 hex digits', + '۶ یا ۸ رقم هگز وارد کنید', + ), + ), + maxLength: 8, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]')), + ], + onChanged: (text) => setState(() { + _valid = text.length == 6 || text.length == 8; + if (_valid) { + _value = + int.parse(text, radix: 16) | + (text.length == 6 ? 0xFF000000 : 0); + } + }), + ), + Text( + '${tr(context, 'Opacity', 'شفافیت')}: ${((_value >> 24 & 255) / 255 * 100).round()}%', + ), + Slider( + key: const Key('custom-color-opacity'), + value: (_value >> 24 & 255) / 255, + min: 0, + max: 1, + onChanged: (value) => setState(() { + _value = ((value * 255).round() << 24) | (_value & 0xFFFFFF); + _text.text = _hex(_value); + _valid = true; + }), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(tr(context, 'Cancel', 'انصراف')), + ), + FilledButton( + onPressed: _valid ? () => Navigator.pop(context, _value) : null, + child: Text(tr(context, 'Save', 'ذخیره')), + ), + ], + ); +} + +class _DelayTile extends StatelessWidget { + const _DelayTile({ + required this.label, + required this.value, + required this.onChanged, + }); + final String label; + final double value; + final ValueChanged onChanged; + @override + Widget build(BuildContext context) => ListTile( + title: Text(label), + trailing: SizedBox( + width: 90, + child: TextFormField( + key: ValueKey('$label:$value'), + initialValue: value.toStringAsFixed(1), + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]')), + ], + onFieldSubmitted: (text) { + final parsed = double.tryParse(text); + if (parsed != null && parsed.isFinite) { + onChanged(parsed.clamp(-60, 60)); + } + }, + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..80bbcec --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,878 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "4d4bbd619d698cbee3c400ba89246ecb0f525146b0ae05389f62ede47abd5bec" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + archive: + dependency: "direct main" + description: + name: archive + sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb + url: "https://pub.dev" + source: hosted + version: "4.6.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + background_downloader: + dependency: "direct main" + description: + path: "third_party/background_downloader" + relative: true + source: path + version: "9.6.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: transitive + description: + name: connectivity_plus + sha256: d7749fd36d8b404a4935612b94eca7c3ce3705377ab678e24ff2a4301436f741 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 + url: "https://pub.dev" + source: hosted + version: "3.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592" + url: "https://pub.dev" + source: hosted + version: "13.3.1" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + url: "https://pub.dev" + source: hosted + version: "2.0.2" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c + url: "https://pub.dev" + source: hosted + version: "4.2.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f + url: "https://pub.dev" + source: hosted + version: "1.1.1" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: "direct dev" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: "direct main" + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2ae480aa3379716b8bb58dfebb168e2919394c92b629efaaa0baaa5d140786e6" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: "direct dev" + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63" + url: "https://pub.dev" + source: hosted + version: "6.5.4" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "76288b689a3e1fddccb377177feed85d73fdfc64c9c3c52991e2d9aba35c4857" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224 + url: "https://pub.dev" + source: hosted + version: "13.3.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "39a1d99085cf3190d41305bca05f0c0d77522f355f7f99bb648ee231192d9d68" + url: "https://pub.dev" + source: hosted + version: "1.8.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + web: + dependency: transitive + description: + name: web + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct main" + description: + name: xml + sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..67b9076 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,49 @@ +name: reelbox +description: An independent film and series client with a device-local library. +publish_to: none +version: 0.4.4+8 + +environment: + sdk: ^3.13.0 + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + dio: 5.11.1 + shared_preferences: 2.5.6 + path_provider: 2.1.6 + media_kit: 1.2.6 + media_kit_video: 2.0.1 + media_kit_libs_android_video: 1.3.8 + background_downloader: 9.6.5 + url_launcher: 6.3.3 + share_plus: 13.3.1 + file_picker: 13.3.1 + wakelock_plus: 1.8.2 + android_intent_plus: 6.1.0 + package_info_plus: 10.2.2 + xml: 7.1.0 + crypto: 3.0.8 + archive: 4.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: 6.0.0 + logging: 1.3.0 + # Host-only real-worker test registration; not an Android runtime dependency. + path_provider_linux: 2.2.2 + +# Pinned 9.6.5 with narrowly scoped multipart resume corrections. +dependency_overrides: + background_downloader: + path: third_party/background_downloader + +flutter: + uses-material-design: true + assets: + - assets/licenses/LGPL-2.1.txt + - assets/licenses/LIBVLC-NOTICE.txt diff --git a/server/banners.json b/server/banners.json new file mode 100644 index 0000000..f6fecae --- /dev/null +++ b/server/banners.json @@ -0,0 +1,3 @@ +{ + "banners": [] +} diff --git a/server/catalog_api.py b/server/catalog_api.py new file mode 100644 index 0000000..09df843 --- /dev/null +++ b/server/catalog_api.py @@ -0,0 +1,431 @@ +"""Read-only ReelBox metadata service. No upstream or media fetches.""" +from collections import OrderedDict +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import sqlite3 +import sys +import threading +import unicodedata + + +class ApiError(Exception): + def __init__(self, status, message): + super().__init__(message) + self.status = status + + +_FOLD = dict.fromkeys(i for i in range(0x110000) + if unicodedata.category(chr(i)) in {'Mn', 'Cf'}) +_FOLD.update({ord('ي'): 'ی', ord('ى'): 'ی', ord('ك'): 'ک'}) + + +def normal(value): + return str(value or '').lower().translate(_FOLD) + + +def allowed_text(value): + text = normal(unicodedata.normalize('NFKC', str(value or ''))) + # Upstream notices are often disguised as movie rows, with new IDs after + # refreshes. Match their labels rather than IDs or bare English "notice", + # which would incorrectly hide films such as Red Notice / Notice to Quit. + label = re.sub(r'[\W_ـ]', '', text) + if label.startswith(( + 'اطلاعیه', 'اطلاعرسانی', 'بهروزرسانی', 'بروزرسانی', + 'اعلانجدید', 'اعلانمهم', 'اعلانفوری', 'اعلامیهجدید', 'اعلامیهمهم', + 'آپدیتجدید', 'اپدیتجدید', + 'newannouncement', 'importantannouncement', 'urgentannouncement', + 'newnotice', 'importantnotice', 'urgentnotice', + 'appupdate', 'applicationupdate', 'softwareupdate')): + return False + compact = re.sub(r'[\s_\-ـ]', '', text) + return not (any(word in compact for word in + ('ملودیباکس', 'ملودیبکس', 'melodybox', 'melodibox', 'نورتون', 'نورتن', 'norton', + 'موویباکس', 'موسویباکس', 'نسخهجدیدهورتون', 'اپلیکیشنهورتون', 'نرمافزارهورتون')) + or 'تلگرام' in text or re.search(r'\btelegram\b|(^|[\s>(])@[a-z][a-z0-9_]{3,}', text) + or re.search(r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)', text) + or re.search(r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)', text)) + + +def banner_https(value): + from urllib.parse import urlsplit + if (not isinstance(value, str) or len(value) > 2048 + or re.search(r'[\x00-\x20\x7f\\]', value) + or re.search(r'%(?![0-9a-f]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)', value, re.I)): + return False + try: + uri = urlsplit(value) + return (uri.scheme == 'https' and bool(uri.hostname) and uri.username is None + and uri.password is None and uri.port in (None, 443) + and len(uri.hostname) <= 253 + and all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?', part) + for part in uri.hostname.split('.'))) + except ValueError: + return False + + +def load_banners(path): + """Read a bounded operator-owned file, never a remote config or admin input.""" + try: + with Path(path).open('rb') as source: + data = source.read(65537) + if len(data) > 65536: + return {'banners': []} + config = json.loads(data) + except (OSError, ValueError, RecursionError): + return {'banners': []} + if not isinstance(config, dict) or not isinstance(config.get('banners'), list): + return {'banners': []} + result = [] + for row in config['banners'][:20]: + if len(result) == 5: + break + if not isinstance(row, dict) or not banner_https(row.get('target_url')): + continue + image = row.get('image_url') + if image not in (None, '') and not banner_https(image): + continue + clean = {} + for key, fallback, limit in ( + ('title_en', '', 80), ('title_fa', row.get('title_en', ''), 80), + ('subtitle_en', '', 180), ('subtitle_fa', row.get('subtitle_en', ''), 180)): + text = row.get(key, fallback) + if (not isinstance(text, str) or len(text) > limit + or re.search(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]', text) + or not allowed_text(text)): + break + clean[key] = text.strip() + else: + if not clean['title_en'] or not clean['title_fa']: + continue + clean['target_url'] = row['target_url'] + if image: + clean['image_url'] = image + result.append(clean) + return {'banners': result} + + +class Catalog: + RESPONSE_CACHE_BYTES = 8 * 1024 * 1024 + RESPONSE_CACHE_ENTRIES = 128 + RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024 + + def __init__(self, path, banner_config=None): + self.banner_config = Path(banner_config) if banner_config is not None else Path(__file__).with_name('banners.json') + self.db = sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro&immutable=1', + uri=True, check_same_thread=False) + self.db.execute('PRAGMA query_only=ON') + self.db.execute('PRAGMA cache_size=-8192') + self.lock = threading.RLock() + self._responses = OrderedDict() + self._response_bytes = 0 + self.metadata = {k: json.loads(v) for k, v in self.db.execute('SELECT * FROM metadata')} + # The snapshot is immutable until service restart. Normalize searchable + # text once instead of redoing Unicode folding for every row/request. + # Bound retained strings on the 256 MiB service; overflow stays correct + # through the original normalization path rather than dropping results. + self.search_text = {} + self.search_index_bytes = 0 + visible = set() + for title_id, kind, title, description in self.db.execute( + 'SELECT id,type,title,description FROM titles'): + if kind not in {'movie', 'serie'} or not allowed_text(title) or not allowed_text(description): + continue + visible.add(title_id) + text = (normal(title), normal(description)) + size = sys.getsizeof(text) + sum(sys.getsizeof(v) for v in text) + sys.getsizeof(title_id) + 64 + if self.search_index_bytes + size <= 64 * 1024 * 1024: + self.search_text[title_id] = text + self.search_index_bytes += size + self.visible = frozenset(visible) + self.db.create_function('visible', 1, lambda value: value in self.visible, deterministic=True) + self.db.create_function('search_match', 4, self.search_match, deterministic=True) + tables = ('titles', 'genres', 'countries', 'movie_sources', 'seasons', 'episodes', 'episode_sources') + self.counts = {name: self.db.execute('SELECT COUNT(*) FROM ' + name).fetchone()[0] for name in tables} + self.counts['visible_titles'] = len(self.visible) + + def search_match(self, title_id, title, description, query): + text = self.search_text.get(title_id) + if text is None: + text = (normal(title), normal(description)) + return query in text[0] or query in text[1] + + def close(self): + self.db.close() + + def health(self): + built = self.metadata.get('built_at_utc') + age = None + if built: + age = max(0, int((datetime.now(timezone.utc) - datetime.fromisoformat(built)).total_seconds())) + return {'status': 'ok', 'counts': self.counts.copy(), + 'snapshot_built_at_utc': built, 'snapshot_age_seconds': age, + 'provenance': self.metadata.get('provenance', 'Exported API snapshot'), + 'offline': True, 'media_proxy': False, + 'snapshot_limitations': ['Not live-synchronised', 'Media URLs may expire', + 'Cast, public lists and subtitle metadata were not exported']} + + @staticmethod + def clean(value): + if isinstance(value, dict): + return {k: Catalog.clean(v) for k, v in value.items() + if k.lower() not in {'comment', 'comments', 'comments_enabled', 'replies'}} + if isinstance(value, list): + return [Catalog.clean(v) for v in value] + return value + + def rows(self, sql, params=()): + return [self.clean(json.loads(row[0])) for row in self.db.execute(sql, params)] + + def tags(self, name): + columns = 'id,title,image' if name == 'countries' else 'id,title' + cursor = self.db.execute('SELECT ' + columns + ' FROM ' + name + ' ORDER BY id') + keys = [column[0] for column in cursor.description] + return [dict(zip(keys, row)) for row in cursor + if allowed_text(row[1]) and not (name == 'genres' and row[0] == 48)] + + def titles(self, kind='poster', genre=0, country=0, order='created', page=0, query=None): + orders = {'created': 't.id DESC', 'updated': 't.id DESC', + 'imdb': 't.imdb DESC,t.id DESC', 'rating': 't.rating DESC,t.id DESC', + 'year': 't.year DESC,t.id DESC', 'title': 't.title COLLATE NOCASE,t.id', + 'random': '(t.id * 2654435761) % 2147483647,t.id'} + if order not in orders or kind not in {'movie', 'serie', 'poster'} or not 0 <= page <= 100000: + raise ApiError(400, 'Unsupported catalog filter') + where, params = ['visible(t.id)'], [] + if kind != 'poster': + where.append('t.type=?') + params.append(kind) + for table, column, value in [('title_genres', 'genre_id', genre), + ('title_countries', 'country_id', country)]: + if value: + where.append('EXISTS (SELECT 1 FROM ' + table + ' f WHERE f.title_id=t.id AND f.' + column + '=?)') + params.append(value) + if query is not None: + where.append('search_match(t.id,t.title,t.description,?)') + params.append(normal(query)) + return self.rows('SELECT t.raw_json FROM titles t WHERE ' + ' AND '.join(where) + + ' ORDER BY ' + orders[order] + ' LIMIT 30 OFFSET ?', params + [page * 30]) + + def home(self): + return {'genres': [ + {'id': -2, 'title': 'Latest in catalogue', 'posters': self.titles()}, + {'id': -3, 'title': 'Highest IMDb ratings', 'posters': self.titles(order='imdb')}, + {'id': -4, 'title': 'Series in catalogue', 'posters': self.titles(kind='serie')} + ], 'actors': [], 'countries': self.tags('countries'), 'slides': []} + + def legacy(self, path, query): + from urllib.parse import parse_qs + try: + raw = parse_qs(query, keep_blank_values=True, max_num_fields=8) + except ValueError: + raise ApiError(400, 'Invalid query') from None + if any(len(values) != 1 for values in raw.values()): + raise ApiError(400, 'Repeated query parameter') + args = {key: values[0] for key, values in raw.items()} + allowed = {'action', 'q', 'pageno'} + if path.endswith('/filter.php'): + allowed = {'action', 'garid', 'pageno'} + elif path.endswith('/star.php'): + allowed = {'key', 'action', 'q', 'pageno'} + elif path.endswith(('/pishnahad.php', '/bartar.php')): + allowed = set() + if args.keys() - allowed or len(args.get('q', '')) > 200 or len(args.get('key', '')) > 256: + raise ApiError(400, 'Unsupported query parameter') + try: + page = int(args.get('pageno') or 1) - 1 + genre = int(args.get('garid') or 0) + except ValueError: + raise ApiError(400, 'Invalid page or genre') from None + if not 0 <= page <= 100000 or not 0 <= genre <= 999999999: + raise ApiError(400, 'Invalid page or genre') + action = args.get('action', '') + if path.endswith(('/pishnahad.php', '/bartar.php')): + return [{'title': [tag['title']], + 'link': ['/api-user/newapi/filter.php?action=pishnahad&garid=' + str(tag['id']) + '&pageno=']} + for tag in self.tags('genres') + if self.db.execute('SELECT 1 FROM title_genres WHERE genre_id=? AND visible(title_id) LIMIT 1', (tag['id'],)).fetchone()] + if path == '/api-user/newapi/filter.php' and action == 'pishnahad': + return self.titles(genre=genre, order='imdb', page=page) + if path == '/api-user/newapi/like.php': + if action in {'search-movie', 'search-movie-en'}: + return self.titles(query=args.get('q', '').strip(), page=page) if args.get('q', '').strip() else [] + if action in {'best-rand-top', 'best-rand-year'}: + return self.titles(order='year' if action.endswith('year') else 'imdb', page=page) + if action in {'all-actor', 'colection', 'search-trand'}: + return [] + if path == '/api-user/filmahang/star.php' and action in {'starsearch', 'mazmon', 'share', 'new'}: + return [] + raise ApiError(404, 'Metadata route not available in snapshot') + + def response(self, target): + # Only immutable REST snapshot metadata. Health/banner freshness and + # search privacy are preserved: no search query is retained in this LRU. + cacheable = (target.startswith('/api/') and '?' not in target + and target.rstrip('/') not in {'/api/health', '/api/banners'}) + with self.lock: + cached = self._responses.get(target) if cacheable else None + if cached is not None: + self._responses.move_to_end(target) + return cached + body = json.dumps(self.dispatch(target), ensure_ascii=False, + separators=(',', ':')).encode('utf-8') + if len(body) > 32 * 1024 * 1024: + raise ApiError(503, 'Metadata response exceeds safety limit') + size = len(body) + len(target.encode('utf-8')) + if (cacheable and size <= self.RESPONSE_CACHE_ITEM_BYTES + and size <= self.RESPONSE_CACHE_BYTES and self.RESPONSE_CACHE_ENTRIES > 0): + while self._responses and ( + self._response_bytes + size > self.RESPONSE_CACHE_BYTES + or len(self._responses) >= self.RESPONSE_CACHE_ENTRIES): + old_target, old_body = self._responses.popitem(last=False) + self._response_bytes -= len(old_body) + len(old_target.encode('utf-8')) + self._responses[target] = body + self._response_bytes += size + return body + + def dispatch(self, target): + from urllib.parse import urlsplit + if len(target) > 4096 or re.search(r'[\x00-\x20\x7f]', target): + raise ApiError(400, 'Invalid metadata route') + try: + parsed = urlsplit(target) + except ValueError: + raise ApiError(400, 'Invalid metadata route') from None + path = parsed.path + if (len(target) > 4096 or parsed.scheme or parsed.netloc or parsed.fragment + or '%' in path or '\\' in path or re.search(r'[\x00-\x20\x7f]', path) + or re.search(r'(^|/)\.{1,2}(/|$)', path)): + raise ApiError(400, 'Invalid metadata route') + key = r'[A-Za-z0-9_-]{0,256}' + with self.lock: + if path in {'/api-user/newapi/like.php', '/api-user/newapi/filter.php', + '/api-user/newapi/pishnahad.php', '/api-user/newapi/bartar.php', + '/api-user/filmahang/star.php'}: + return self.legacy(path, parsed.query) + if parsed.query: + raise ApiError(400, 'Unexpected query parameters') + if path in {'/api/banners', '/api/banners/'}: + return load_banners(self.banner_config) + if path in {'/api/health', '/api/health/'}: + return self.health() + if re.fullmatch(r'/api/first/' + key + '/', path): + return self.home() + if re.fullmatch(r'/api/genre/all/' + key + '/', path): + return self.tags('genres') + if re.fullmatch(r'/api/country/all/' + key + '/', path): + return self.tags('countries') + match = re.fullmatch(r'/api/(movie|serie|poster)/by/filtres/(\d{1,9})/(?:(\d{1,9})/)?([a-z]+)/([0-9]{1,6})/' + key + '/', path) + if match: + kind, genre, country, order, page = match.groups() + return self.titles(kind, int(genre), int(country or 0), order, int(page)) + match = re.fullmatch(r'/api/movie/by/([0-9]{1,10})/' + key + '/', path) + if match: + title_id = int(match[1]) + if title_id not in self.visible: + raise ApiError(404, 'Title not in snapshot') + item = self.rows('SELECT raw_json FROM titles WHERE id=?', (title_id,))[0] + item['sources'] = self.rows('SELECT raw_json FROM movie_sources WHERE title_id=? ORDER BY source_index', (title_id,)) + return item + match = re.fullmatch(r'/api/season/by/serie/([0-9]{1,10})/' + key + '/', path) + if match: + title_id = int(match[1]) + if title_id not in self.visible: + raise ApiError(404, 'Title not in snapshot') + return self.rows('SELECT raw_json FROM seasons WHERE series_id=? ORDER BY season_index', (title_id,)) + if re.fullmatch(r'/api/(?:role/by/poster|movie/by/actor|subtitles/by/(?:movie|episode))/[0-9]{1,10}/' + key + '/', path): + return [] + raise ApiError(404, 'Unknown metadata route') + + +def make_server(catalog, host='127.0.0.1', port=18765): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + class Handler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.0' + + def setup(self): + self.request.settimeout(10) + super().setup() + + def log_message(self, *_args): + pass # Never record source links, search terms or shared route keys. + + def reply(self, status, value): + body = value if isinstance(value, bytes) else json.dumps( + value, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + if len(body) > 32 * 1024 * 1024: + status, body = 503, b'{"error":"Metadata response exceeds safety limit"}' + self.send_response_only(status) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Cache-Control', 'no-store') + self.send_header('Connection', 'close') + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(body) + self.close_connection = True + + def do_GET(self): + try: + if self.headers.get('Transfer-Encoding') or self.headers.get('Content-Length', '0') != '0': + raise ApiError(400, 'Request body not supported') + self.reply(200, catalog.response(self.path)) + except ApiError as error: + self.reply(error.status, {'error': str(error)}) + except (BrokenPipeError, ConnectionResetError, TimeoutError): + self.close_connection = True + except Exception: + self.reply(503, {'error': 'Catalogue temporarily unavailable'}) + + do_HEAD = do_GET + + def reject_write(self): + self.reply(405, {'error': 'Read-only metadata API'}) + + do_POST = do_PUT = do_DELETE = do_PATCH = do_CONNECT = do_OPTIONS = do_TRACE = reject_write + + class Server(ThreadingHTTPServer): + daemon_threads = True + request_queue_size = 16 + + def __init__(self, address): + self.slots = threading.BoundedSemaphore(16) + super().__init__(address, Handler) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except Exception: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + if host != '127.0.0.1': + raise ValueError('Bind behind the HTTPS reverse proxy on IPv4 loopback only') + return Server((host, port)) + + +def main(): + import os + catalog = Catalog(os.environ.get('REELBOX_CATALOG_DB', '/var/lib/reelbox-api/catalog.sqlite'), + banner_config=os.environ.get('REELBOX_BANNER_CONFIG', '/var/lib/reelbox-api/banners.json')) + server = make_server(catalog) + try: + server.serve_forever() + finally: + server.server_close() + catalog.close() + + +if __name__ == '__main__': + main() diff --git a/server/catalog_sync.py b/server/catalog_sync.py new file mode 100644 index 0000000..c926204 --- /dev/null +++ b/server/catalog_sync.py @@ -0,0 +1,419 @@ +"""Metadata-only catalogue synchronization; publication is the caller's job. + +Only fixed-origin, allowlisted metadata GETs are possible. Image/video URLs are +opaque stored metadata and are never followed. Supply the key from the calling +process (e.g. REELBOX_UPSTREAM_KEY). Reports and errors contain no request URLs. +""" +from contextlib import closing +from datetime import datetime, timezone +import http.client +import json +import math +import os +from pathlib import Path +import re +import sqlite3 +import ssl +import time +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +UPSTREAM_BASE = 'https://ninwinios.info/api/' +MAX_RESPONSE_BYTES = 32 * 1024 * 1024 +_ROUTE = re.compile(r'(?:genre/all|country/all|(?:movie|serie)/by/filtres/0/created/[0-9]{1,6}' + r'|movie/by/[1-9][0-9]{0,9}|season/by/serie/[1-9][0-9]{0,9})') + + +class SyncError(RuntimeError): + """Safe, key-free error suitable for a scheduler log.""" + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise SyncError('Upstream redirect denied') + + +def _invalid_constant(_value): + raise ValueError('Non-finite JSON number') + + +class Upstream: + """Single-worker rate-limited client. ``attempts`` is total attempts, <=10. + + ``opener`` is an injectable trusted urllib-compatible opener for tests. + ``cache_dir`` is reserved; responses are intentionally not cached on disk, + so a subsequent run always refreshes metadata. No response/key is logged. + """ + def __init__(self, key, interval=0.25, timeout=30, attempts=3, opener=None, cache_dir=None): + if not isinstance(key, str) or not key or len(key) > 4096: + raise ValueError('Upstream key required') + if (type(attempts) is not int or not 1 <= attempts <= 10 + or not isinstance(interval, (int, float)) or not math.isfinite(interval) or interval < 0 + or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0): + raise ValueError('Invalid upstream retry or timing configuration') + self._key = key + self.interval, self.timeout, self.attempts = interval, timeout, attempts + self._opener = opener if opener is not None else build_opener(_NoRedirect()) + self.requests_count = 0 + self._last_request = None + + def get(self, path): + if not isinstance(path, str) or not _ROUTE.fullmatch(path): + raise ValueError('Unsupported upstream metadata route') + url = UPSTREAM_BASE + path + '/' + quote(self._key, safe='') + '/' + parsed = urlsplit(url) + if (parsed.scheme, parsed.netloc, parsed.query, parsed.fragment) != ('https', 'ninwinios.info', '', ''): + raise ValueError('Unsupported upstream origin') + request = Request(url, headers={'Accept': 'application/json', 'Accept-Encoding': 'identity'}, method='GET') + for attempt in range(self.attempts): + if self._last_request is not None: + delay = self.interval - (time.monotonic() - self._last_request) + if delay > 0: + time.sleep(delay) + self._last_request = time.monotonic() + self.requests_count += 1 + try: + with self._opener.open(request, timeout=self.timeout) as response: + status = response.status + if status == 429 or 500 <= status <= 599: + raise HTTPError(url, status, 'Upstream temporarily unavailable', {}, None) + if status != 200 or response.geturl() != url: + raise SyncError('Upstream status or redirect denied') + media_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower() + # The original PHP API labels valid JSON as text/html. + # Only allow this known legacy label; parsing/schema checks still apply. + if media_type and media_type not in {'application/json', 'text/html'} and not media_type.endswith('+json'): + raise SyncError('Upstream did not return JSON metadata') + declared = response.headers.get('Content-Length') + if declared is not None: + if not re.fullmatch(r'[0-9]{1,12}', declared) or int(declared) > MAX_RESPONSE_BYTES: + raise SyncError('Invalid upstream metadata length') + body = response.read(MAX_RESPONSE_BYTES + 1) + if len(body) > MAX_RESPONSE_BYTES: + raise SyncError('Upstream metadata response exceeds safety limit') + if declared is not None and len(body) != int(declared): + raise SyncError('Truncated upstream metadata response') + value = json.loads(body, parse_constant=_invalid_constant) + detail = path.startswith('movie/by/') and '/filtres/' not in path + if (detail and not isinstance(value, dict)) or (not detail and not isinstance(value, list)): + raise SyncError('Unexpected upstream metadata shape') + return value + except HTTPError as error: + retry = error.code == 429 or 500 <= error.code <= 599 + error.close() + if not retry or attempt + 1 == self.attempts: + raise SyncError('Upstream HTTP request failed') from None + except (ssl.SSLError, ValueError, RecursionError): + raise SyncError('Invalid upstream TLS or JSON metadata') from None + except (URLError, OSError, http.client.HTTPException) as error: + if isinstance(getattr(error, 'reason', None), ssl.SSLError) or attempt + 1 == self.attempts: + raise SyncError('Upstream transport request failed') from None + if attempt + 1 < self.attempts: + time.sleep(min(2 ** attempt, 8)) + raise SyncError('Upstream request exhausted') + + +try: + from .catalog_api import allowed_text +except ImportError: # Also support `PYTHONPATH=server; import catalog_sync`. + from catalog_api import allowed_text + + +def _encode(value): + return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False) + + +def _id(value): + if type(value) is not int or not 1 <= value <= 9999999999: + raise SyncError('Malformed metadata identifier') + return value + + +def _objects(value): + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise SyncError('Malformed metadata list') + return value + + +def _merge(old, incoming): + result = dict(old) + for key, value in incoming.items(): + if key in {'sources', 'episodes', 'genres', 'country'}: + result[key] = _merge_sequence(old.get(key, []), value) + elif key not in result or (value is not None and value != ''): + result[key] = value + return result + + +def _merge_sequence(old, incoming): + """Retain old positions; stable IDs win, missing IDs fall back to index.""" + old, incoming = _objects(old), _objects(incoming) + result = list(old) + by_id = {item['id']: index for index, item in enumerate(old) if item.get('id') is not None} + seen_ids, matched = set(), set() + for index, item in enumerate(incoming): + identity = item.get('id') + if identity is not None: + _id(identity) + if identity in seen_ids: + raise SyncError('Duplicate nested metadata identifier') + seen_ids.add(identity) + target = by_id.get(identity) + if target is None and index < len(old) and old[index].get('id') is None: + target = index + else: + target = index if index < len(old) else None + if target is not None: + if target in matched: + raise SyncError('Ambiguous nested metadata identity') + matched.add(target) + result[target] = _merge(old[target], item) + else: + result.append(_merge({}, item)) + return result + + +def _validate_record(item, kind, require_title=False): + if not isinstance(item, dict) or item.get('error') or item.get('errors'): + raise SyncError('Malformed metadata record') + if item.get('id') is not None: + _id(item['id']) + if kind in {'season', 'episode', 'source'} and item.get('id') is None: + identity = item.get('url') if kind == 'source' else item.get('title') + if not isinstance(identity, str) or not identity.strip(): + raise SyncError('Nested metadata has no usable identity') + if require_title and (not isinstance(item.get('title'), str) or not item['title'].strip()): + raise SyncError('New title has no usable name') + for field in ('title', 'description', 'duration', 'classification', 'image', 'cover', + 'downloadas', 'playas', 'type', 'quality', 'url'): + if field in item and item[field] is not None and not isinstance(item[field], str): + raise SyncError('Malformed text metadata field') + for field in ('year', 'imdb', 'rating'): + value = item.get(field) + if value is not None and (type(value) not in {int, float} or not math.isfinite(value)): + raise SyncError('Malformed numeric metadata field') + if 'comment' in item and item['comment'] is not None and type(item['comment']) not in {bool, int}: + raise SyncError('Malformed comment metadata flag') + for field, nested_kind in [('sources', 'source'), ('episodes', 'episode'), + ('genres', 'reference'), ('country', 'reference')]: + if field in item: + for nested in _objects(item[field]): + _validate_record(nested, nested_kind) + + +def _reference(db, table, item): + _validate_record(item, 'reference') + _id(item.get('id')) + columns = ['id', 'title', 'image'] if table == 'countries' else ['id', 'title'] + db.execute('INSERT INTO ' + table + '(' + ','.join(columns) + ') VALUES (' + + ','.join('?' for _ in columns) + ') ON CONFLICT(id) DO UPDATE SET ' + + ','.join(c + '=COALESCE(excluded.' + c + ',' + table + '.' + c + ')' for c in columns[1:]), + [item.get(c) for c in columns]) + + +def _save_title(db, incoming): + title_id = _id(incoming.get('id')) + record = db.execute('SELECT raw_json FROM titles WHERE id=?', (title_id,)).fetchone() + _validate_record(incoming, 'title', require_title=not record) + old = json.loads(record[0]) if record else {} + merged = _merge(old, incoming) + raw = _encode(merged) + if record and old == merged: + return False + fields = ['id', 'type', 'title', 'description', 'year', 'imdb', 'rating', 'duration', + 'classification', 'image', 'cover', 'downloadas', 'playas', 'comments_enabled', 'raw_json'] + values = [merged.get(c) for c in fields] + values[-2], values[-1] = merged.get('comment'), raw + db.execute('INSERT INTO titles(' + ','.join(fields) + ') VALUES (' + ','.join('?' for _ in fields) + + ') ON CONFLICT(id) DO UPDATE SET ' + ','.join(c + '=excluded.' + c for c in fields[1:]), values) + for field, table, junction, column in [('genres', 'genres', 'title_genres', 'genre_id'), + ('country', 'countries', 'title_countries', 'country_id')]: + for tag in _objects(merged.get(field, [])): + _reference(db, table, tag) + db.execute('INSERT OR IGNORE INTO ' + junction + '(title_id,' + column + ') VALUES (?,?)', + (title_id, tag['id'])) + for index, source in enumerate(_objects(merged.get('sources', []))): + db.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?) ON CONFLICT(title_id,source_index) ' + 'DO UPDATE SET source_id=excluded.source_id,quality=excluded.quality,' + 'media_type=excluded.media_type,url=excluded.url,raw_json=excluded.raw_json', + (title_id, index, source.get('id'), source.get('quality'), source.get('type'), + source.get('url'), _encode(source))) + return True + + +def _save_seasons(db, series_id, incoming): + for season in _objects(incoming): + _validate_record(season, 'season') + previous = db.execute('SELECT season_index,raw_json FROM seasons WHERE series_id=? ORDER BY season_index', + (series_id,)).fetchall() + old = [json.loads(raw) for _, raw in previous] + merged = _merge_sequence(old, incoming) + changed = False + # Existing indices are retained even if an exported snapshot has gaps. + indices = [index for index, _ in previous] + next_index = max(indices, default=-1) + 1 + indices += list(range(next_index, next_index + len(merged) - len(indices))) + for position, season in enumerate(merged): + if position < len(old) and season == old[position]: + continue + changed = True + si = indices[position] + db.execute('INSERT INTO seasons VALUES (?,?,?,?,?) ON CONFLICT(series_id,season_index) DO UPDATE SET ' + 'id=excluded.id,title=excluded.title,raw_json=excluded.raw_json', + (series_id, si, season.get('id'), season.get('title'), _encode(season))) + for ei, episode in enumerate(_objects(season.get('episodes', []))): + raw = _encode(episode) + current = db.execute('SELECT raw_json FROM episodes WHERE series_id=? AND season_index=? AND episode_index=?', + (series_id, si, ei)).fetchone() + if current and json.loads(current[0]) == episode: + continue + db.execute('INSERT INTO episodes VALUES (?,?,?,?,?,?,?,?,?,?) ' + 'ON CONFLICT(series_id,season_index,episode_index) DO UPDATE SET ' + 'id=excluded.id,title=excluded.title,description=excluded.description,duration=excluded.duration,' + 'downloadas=excluded.downloadas,playas=excluded.playas,raw_json=excluded.raw_json', + (series_id, si, ei, episode.get('id'), episode.get('title'), episode.get('description'), + episode.get('duration'), episode.get('downloadas'), episode.get('playas'), raw)) + for source_index, source in enumerate(_objects(episode.get('sources', []))): + db.execute('INSERT INTO episode_sources VALUES (?,?,?,?,?,?,?,?,?) ' + 'ON CONFLICT(series_id,season_index,episode_index,source_index) DO UPDATE SET ' + 'source_id=excluded.source_id,quality=excluded.quality,media_type=excluded.media_type,' + 'url=excluded.url,raw_json=excluded.raw_json', + (series_id, si, ei, source_index, source.get('id'), source.get('quality'), + source.get('type'), source.get('url'), _encode(source))) + db.execute('INSERT INTO series_fetch_status VALUES (?,?,?,?) ON CONFLICT(series_id) DO UPDATE SET ' + 'status=excluded.status,season_count=excluded.season_count,source_url=excluded.source_url', + (series_id, 'fetched', len(merged), UPSTREAM_BASE + f'season/by/serie/{series_id}/')) + return changed + + +def _counts(db): + tables = ('titles', 'genres', 'countries', 'movie_sources', 'seasons', 'episodes', 'episode_sources') + counts = {name: db.execute('SELECT COUNT(*) FROM ' + name).fetchone()[0] for name in tables} + for name, kind in [('movies', 'movie'), ('series', 'serie')]: + counts[name] = db.execute('SELECT COUNT(*) FROM titles WHERE type=?', (kind,)).fetchone()[0] + counts['sources'] = counts['movie_sources'] + counts['episode_sources'] + counts['visible_titles'] = sum(1 for kind, title, description in db.execute('SELECT type,title,description FROM titles') + if kind in {'movie', 'serie'} and allowed_text(title) and allowed_text(description)) + return counts + + +def synchronize(source_db: Path, staged_db: Path, upstream, max_pages=2000, progress=None) -> dict: + """Copy a snapshot into an exclusively created candidate and refresh metadata. + + Never publishes or mutates the source. A failed candidate is removed. The + caller owns locking, free-space checks, atomic publication and API reload. + ``progress`` accepts dictionaries containing only phase/count/total. + Both catalogue types must reach an empty page within ``max_pages`` each; + all visible series are refreshed, including those absent from those pages. + The returned updated-series IDs are a sorted sample of at most 100, with + ``updated_series_count`` reporting the full count. No media is fetched. + """ + if type(max_pages) is not int or not 1 <= max_pages <= 1000000: + raise ValueError('Invalid catalogue page limit') + source_db, staged_db = Path(source_db), Path(staged_db) + if source_db.resolve() == staged_db.resolve(): + raise ValueError('Candidate must differ from source') + created, db = False, None + start_requests = getattr(upstream, 'requests_count', None) + + def emit(phase, count, total): + if progress is not None: + progress({'phase': phase, 'count': count, 'total': total}) + + try: + emit('copy', 0, 1) + with closing(sqlite3.connect(source_db.resolve().as_uri() + '?mode=ro', uri=True)) as source: + fd = os.open(staged_db, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + created = True + os.close(fd) + db = sqlite3.connect(staged_db) + source.backup(db) + emit('copy', 1, 1) + db.execute('PRAGMA foreign_keys=ON') + db.execute('PRAGMA journal_mode=DELETE') + db.execute('BEGIN IMMEDIATE') + before = _counts(db) + metadata = {k: json.loads(v) for k, v in db.execute('SELECT * FROM metadata')} + for count, (route, table) in enumerate([('genre/all', 'genres'), ('country/all', 'countries')], 1): + for item in _objects(upstream.get(route)): + _reference(db, table, item) + emit('references', count, 2) + updated_series = set() + for kind in ('movie', 'serie'): + seen = set() + for page in range(max_pages): + items = _objects(upstream.get(f'{kind}/by/filtres/0/created/{page}')) + emit(kind + '_pages', page + 1, max_pages) + if not items: + break + for item in items: + title_id = _id(item.get('id')) + if title_id in seen: + continue + seen.add(title_id) + if item.get('type') == 'channel': + continue + if item.get('type') != kind: + raise SyncError('Unexpected catalogue title type') + if not allowed_text(item.get('title')) or not allowed_text(item.get('description')): + continue + exists = db.execute('SELECT type FROM titles WHERE id=?', (title_id,)).fetchone() + if exists and exists[0] != kind: + raise SyncError('Catalogue title type changed') + if kind == 'movie' and not exists: + detail = upstream.get(f'movie/by/{title_id}') + if not isinstance(detail, dict) or detail.get('id') != title_id or detail.get('type') != kind: + raise SyncError('Malformed movie detail') + item = _merge(item, detail) + if not allowed_text(item.get('title')) or not allowed_text(item.get('description')): + continue + if _save_title(db, item) and kind == 'serie': + updated_series.add(title_id) + else: + raise SyncError('Catalogue pagination did not reach an empty terminal page') + series = [row[0] for row in db.execute("SELECT id,title,description FROM titles WHERE type='serie'") + if allowed_text(row[1]) and allowed_text(row[2])] + for index, series_id in enumerate(series, 1): + seasons = _objects(upstream.get(f'season/by/serie/{series_id}')) + if _save_seasons(db, series_id, seasons): + updated_series.add(series_id) + emit('series', index, len(series)) + after = _counts(db) + completed = datetime.now(timezone.utc).isoformat() + report = {'status': 'ok', 'counts': after, + 'added': {name: after[name] - before[name] for name in + ('titles', 'movies', 'series', 'episodes', 'sources')}, + 'updated_series_ids': sorted(updated_series)[:100], 'updated_series_count': len(updated_series), + 'media_files_downloaded': False, 'synchronized_at_utc': completed, + 'upstream_base': UPSTREAM_BASE} + end_requests = getattr(upstream, 'requests_count', None) + if type(start_requests) is int and type(end_requests) is int: + report['requests_count'] = end_requests - start_requests + metadata.setdefault('original_snapshot_built_at_utc', metadata.get('built_at_utc')) + metadata.update({'built_at_utc': completed, 'synchronized_at_utc': completed, + 'sync_interval_seconds': 259200, 'media_files_downloaded': False, + 'counts': after, 'last_sync': report, 'catalog_pagination_complete': True, + 'series_episode_fetch_complete': True}) + db.executemany('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json', + [(k, _encode(v)) for k, v in metadata.items()]) + if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: + raise SyncError('Candidate integrity check failed') + if db.execute('PRAGMA foreign_key_check').fetchall(): + raise SyncError('Candidate foreign key check failed') + db.commit() + db.close() + db = None + emit('complete', 1, 1) + return report + except BaseException as error: + if db is not None: + db.close() + if created: + for suffix in ('', '-journal', '-wal', '-shm'): + Path(str(staged_db) + suffix).unlink(missing_ok=True) + if isinstance(error, (KeyboardInterrupt, SystemExit, FileExistsError, SyncError)): + raise + raise SyncError('Catalogue synchronization failed; candidate discarded') from None + diff --git a/server/catalog_sync_runner.py b/server/catalog_sync_runner.py new file mode 100644 index 0000000..a9c424d --- /dev/null +++ b/server/catalog_sync_runner.py @@ -0,0 +1,228 @@ +"""Stage, validate and atomically publish read-only catalogue updates.""" +import argparse +from contextlib import closing +from datetime import datetime, timezone +import fcntl +import json +import os +from pathlib import Path +import sqlite3 +import stat +import subprocess +import time +import uuid + + +class UpdateError(Exception): + pass + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def fsync_directory(path): + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def atomic_json(path, value): + temp = path.with_name('.' + path.name + '.' + uuid.uuid4().hex) + try: + with temp.open('x', encoding='utf-8') as stream: + os.chmod(temp, 0o600) + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp, path) + fsync_directory(path.parent) + finally: + temp.unlink(missing_ok=True) + + +def check_candidate(source, stage): + with closing(sqlite3.connect(source.resolve().as_uri() + '?mode=ro', uri=True)) as old: + with closing(sqlite3.connect(stage.resolve().as_uri() + '?mode=ro', uri=True)) as candidate: + if candidate.execute('PRAGMA quick_check').fetchone()[0] != 'ok': + raise UpdateError('Candidate integrity check failed') + if candidate.execute('PRAGMA foreign_key_check').fetchone() is not None: + raise UpdateError('Candidate relation check failed') + for table in ('titles', 'genres', 'countries', 'title_genres', 'title_countries', + 'series_fetch_status', 'seasons', 'episodes', + 'movie_sources', 'episode_sources'): + before = old.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0] + after = candidate.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0] + if after < before: + raise UpdateError('Candidate would remove existing catalogue entries') + source_counts = ( + 'SELECT title_id, COUNT(*) FROM movie_sources GROUP BY title_id', + '''SELECT e.series_id, e.id, + CASE WHEN e.id IS NULL THEN e.season_index END, + CASE WHEN e.id IS NULL THEN e.episode_index END, COUNT(*) + FROM episodes AS e JOIN episode_sources AS s + USING (series_id, season_index, episode_index) + GROUP BY 1, 2, 3, 4''', + ) + for query in source_counts: + after = {row[:-1]: row[-1] for row in candidate.execute(query)} + for row in old.execute(query): + if after.get(row[:-1], 0) < row[-1]: + raise UpdateError('Candidate would remove existing catalogue sources') + row = candidate.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone() + if row is None or not isinstance(json.loads(row[0]), str): + raise UpdateError('Candidate timestamp missing') + return json.loads(row[0]) + + +def run_update(database, state_dir, upstream, *, synchronize_fn=None, + restart_fn=None, health_fn=None, max_pages=2000, progress=None): + database, state_dir = Path(database).resolve(), Path(state_dir).resolve() + state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + if synchronize_fn is None: + from catalog_sync import synchronize + synchronize_fn = synchronize + restart_fn = restart_fn or restart_api + health_fn = health_fn or wait_for_api + with (state_dir / 'sync.lock').open('a') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise UpdateError('A catalogue update is already running') from None + original = database.stat() + identity = (original.st_ino, original.st_size, original.st_mtime_ns) + stage = database.with_name('.catalog-sync-' + uuid.uuid4().hex + '.sqlite') + backup = database.with_name('catalog.previous.sqlite') + backup_temp = database.with_name('.catalog-backup-' + uuid.uuid4().hex) + published = False + started = now() + atomic_json(state_dir / 'last-result.json', {'status': 'running', 'started_at_utc': started}) + try: + with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as old: + row = old.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone() + if row is None or not isinstance(json.loads(row[0]), str): + raise UpdateError('Previous catalogue timestamp missing') + previous_stamp = json.loads(row[0]) + report = synchronize_fn(database, stage, upstream, max_pages=max_pages, progress=progress) + stamp = check_candidate(database, stage) + current = database.stat() + if identity != (current.st_ino, current.st_size, current.st_mtime_ns): + raise UpdateError('Live catalogue changed during synchronisation') + os.chown(stage, original.st_uid, original.st_gid) + os.chmod(stage, stat.S_IMODE(original.st_mode)) + with stage.open('rb') as stream: + os.fsync(stream.fileno()) + os.link(database, backup_temp) + os.replace(backup_temp, backup) + fsync_directory(database.parent) + os.replace(stage, database) + published = True + fsync_directory(database.parent) + restart_fn() + health_fn(stamp) + result = {**report, 'status': 'success', 'started_at_utc': started, + 'completed_at_utc': now(), 'api_health_verified': True, + 'media_files_downloaded': False} + atomic_json(state_dir / 'last-success.json', result) + atomic_json(state_dir / 'last-result.json', result) + return result + except Exception as error: + rollback_failed = False + if published: + try: + os.replace(backup, database) + fsync_directory(database.parent) + restart_fn() + health_fn(previous_stamp) + except Exception: + rollback_failed = True + atomic_json(state_dir / 'last-result.json', { + 'status': 'failed', 'started_at_utc': started, 'completed_at_utc': now(), + 'error_type': type(error).__name__, 'rollback_failed': rollback_failed, + 'media_files_downloaded': False, + }) + if rollback_failed: + raise UpdateError('Update failed and API restart needs operator attention') from None + raise UpdateError('Update failed; the previous catalogue was retained') from None + finally: + for suffix in ('', '-journal', '-wal', '-shm'): + Path(str(stage) + suffix).unlink(missing_ok=True) + backup_temp.unlink(missing_ok=True) + + +def restart_api(): + subprocess.run(['/bin/systemctl', 'restart', 'reelbox-api.service'], + check=True, capture_output=True, timeout=120) + + +def wait_for_api(expected_stamp): + from urllib.request import ProxyHandler, build_opener + opener = build_opener(ProxyHandler({})) + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + try: + with opener.open('http://127.0.0.1:18765/api/health', timeout=5) as response: + health = json.loads(response.read(65536)) + if (health.get('status') == 'ok' and health.get('media_proxy') is False + and health.get('snapshot_built_at_utc') == expected_stamp): + return + except Exception: + pass + time.sleep(2) + raise UpdateError('Updated API did not become healthy in time') + + +def emit_progress(event): + fields = {key: event[key] for key in ('phase', 'total') if key in event} + if 'completed' in event or 'count' in event: + fields['completed'] = event.get('completed', event.get('count')) + print(json.dumps({'progress': fields}), flush=True) + + +def probe(upstream): + genres = upstream.get('genre/all') + movies = upstream.get('movie/by/filtres/0/created/0') + series = upstream.get('serie/by/filtres/0/created/0') + if not all(isinstance(rows, list) and rows for rows in (genres, movies, series)): + raise UpdateError('Upstream metadata probe returned an unexpected shape') + movie_id = movies[0]['id'] + series_id = series[0]['id'] + detail = upstream.get('movie/by/' + str(movie_id)) + seasons = upstream.get('season/by/serie/' + str(series_id)) + if not isinstance(detail, dict) or not isinstance(seasons, list): + raise UpdateError('Upstream detail or season probe failed') + return {'status': 'probe_ok', 'movie_id': movie_id, 'series_id': series_id, + 'movie_sources': len(detail.get('sources', [])), 'seasons': len(seasons), + 'media_files_downloaded': False} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--database', type=Path, default=Path('/var/lib/reelbox-api/catalog.sqlite')) + parser.add_argument('--state-dir', type=Path, default=Path('/var/lib/reelbox-sync')) + parser.add_argument('--interval', type=float, default=0.25) + parser.add_argument('--max-pages', type=int, default=2000) + parser.add_argument('--probe', action='store_true') + args = parser.parse_args() + from catalog_sync import Upstream + key = os.environ.get('REELBOX_UPSTREAM_KEY', '') + if not key: + print('REELBOX_UPSTREAM_KEY is required; no requests sent', flush=True) + return 2 + try: + upstream = Upstream(key, interval=max(0.25, args.interval)) + result = probe(upstream) if args.probe else run_update( + args.database, args.state_dir, upstream, max_pages=args.max_pages, progress=emit_progress) + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 + except Exception as error: + print(json.dumps({'status': 'failed', 'error_type': type(error).__name__, + 'message': str(error) if isinstance(error, UpdateError) else 'Metadata update failed; see safe status record'}), flush=True) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/server/data-provenance/manifest.json b/server/data-provenance/manifest.json new file mode 100644 index 0000000..24e43f7 --- /dev/null +++ b/server/data-provenance/manifest.json @@ -0,0 +1,68 @@ +{ + "format_version": 1, + "built_at_utc": "2026-11-02T07:00:18.228980+00:00", + "provenance": "API snapshot reconstructed from the read-only endpoints used by the downloaded Simba apps; not an original server-side database dump.", + "api_base": "https://ninwinios.info/api/", + "discovery_url": "http://ninnies-server.info/api-user/newapi/config-server186.txt", + "download_page": "https://zil.ink/movieboxapp", + "counts": { + "titles": 29931, + "genres": 50, + "countries": 84, + "movie_sources": 53853, + "seasons": 19132, + "episodes": 348897, + "episode_sources": 441945, + "movie": 20435, + "serie": 9496, + "series_seasons_fetched": 9496, + "series_seasons_pending": 0, + "movie_titles_without_sources": 8 + }, + "catalog_coverage": { + "movie": { + "kind": "movie", + "first_empty_page": 682, + "pages": 682, + "records": 20435, + "unique_ids": 20435, + "last_nonempty_page_count": 5 + }, + "serie": { + "kind": "serie", + "first_empty_page": 317, + "pages": 317, + "records": 9496, + "unique_ids": 9496, + "last_nonempty_page_count": 16 + } + }, + "catalog_pagination_complete": true, + "series_episode_fetch_complete": true, + "media_files_downloaded": false, + "media_urls_playback_checked": false, + "notes": [ + "API pagination was exhausted, but the service is live and does not offer transactional snapshots or independent total counts.", + "Titles and media URLs are preserved as returned; dead URLs and catalog omissions may exist.", + "No accounts, private profiles, user data, comments or payment data were exported.", + "Extraction does not establish rights to redistribute metadata or stream the referenced videos." + ], + "artifacts": { + "catalog.sqlite": { + "bytes": 598843392, + "sha256": "45213410c51eeee984a02883823e312272bd6c6a44ec75452e9f90f74db1a9d8" + }, + "catalog.jsonl.gz": { + "bytes": 13425106, + "sha256": "a3ef9cba408e554e4b18cd4f8ededb2d69010972a02d7f0182f15fedc669eeae" + }, + "seasons.jsonl.gz": { + "bytes": 6530014, + "sha256": "c1dc5e96a40f33587c6bcaf0dd1a3573d92a693c0756bbef11778ccc91ae6668" + }, + "schema.sql": { + "bytes": 2375, + "sha256": "fd6deb8ed09540aeae8ebb00a621cbfefff19d4fd86e48d33edb28117e298066" + } + } +} \ No newline at end of file diff --git a/server/data-provenance/provenance.json b/server/data-provenance/provenance.json new file mode 100644 index 0000000..04d369f --- /dev/null +++ b/server/data-provenance/provenance.json @@ -0,0 +1,18 @@ +{ + "download_page": "https://zil.ink/movieboxapp", + "inspection": "Static archive extraction; neither app was executed", + "binaries": [ + { + "file": "Simba-v82.apk", + "url": "https://ninamacos.info/apk/Simba-v82.apk", + "bytes": 21415761, + "sha256": "089b742354ef868cdd1b49e0bb8f593f7c1c0df1e35d8a893da3fff1c05bea3c" + }, + { + "file": "Simba-windows.zip", + "url": "https://ninamacos.info/apk/windows/Simba-Setup-1.3.5-x64.zip", + "bytes": 191886111, + "sha256": "e7904367e539f832b69991b067c489d4174b46a1918277e2bfc366e36f748754" + } + ] +} \ No newline at end of file diff --git a/server/install_nginx_route.py b/server/install_nginx_route.py new file mode 100644 index 0000000..c6f3120 --- /dev/null +++ b/server/install_nginx_route.py @@ -0,0 +1,23 @@ +"""Install API routing on the existing Iran ReelBox HTTPS server.""" +from datetime import datetime, timezone +from pathlib import Path +import shutil +import subprocess + +config = Path('/etc/reelbox/nginx.conf') +original = config.read_text() +include = ' include /etc/reelbox/api-locations.conf;\n' +if include not in original: + anchor = ' root /var/www/reelbox/current;\n' + if original.count(anchor) != 1: + raise RuntimeError('Unexpected site configuration; refusing to alter it') + backup = config.with_name('nginx.conf.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '.backup') + shutil.copy2(config, backup) + config.write_text(original.replace(anchor, include + anchor)) + try: + subprocess.run(['/usr/sbin/nginx', '-t', '-c', str(config)], check=True) + except Exception: + config.write_text(original) + raise +subprocess.run(['systemctl', 'reload', 'reelbox-web'], check=True) +print('ReelBox metadata API routes enabled; website root and existing listeners unchanged') diff --git a/server/nginx-api-locations.conf b/server/nginx-api-locations.conf new file mode 100644 index 0000000..6e317dd --- /dev/null +++ b/server/nginx-api-locations.conf @@ -0,0 +1,42 @@ +# Include inside the ReelBox HTTPS server on 443 (legacy 8443 remains served). +# Preserve website/download routes and unrelated services; inspect each host. +location /api/ { + access_log off; + # Public metadata only; low compression level trades little CPU for fewer + # transferred bytes. Nginx handles negotiation, HEAD and chunked framing. + gzip on; + gzip_types application/json; + gzip_min_length 1024; + gzip_comp_level 2; + gzip_vary on; + gzip_proxied no-store; + limit_except GET { deny all; } + proxy_pass http://127.0.0.1:18765; + proxy_http_version 1.0; + proxy_set_header Host localhost; + proxy_set_header Connection close; + proxy_connect_timeout 3s; + proxy_read_timeout 30s; + proxy_send_timeout 10s; + proxy_max_temp_file_size 0; +} +location /api-user/ { + access_log off; + # Public metadata only; low compression level trades little CPU for fewer + # transferred bytes. Nginx handles negotiation, HEAD and chunked framing. + gzip on; + gzip_types application/json; + gzip_min_length 1024; + gzip_comp_level 2; + gzip_vary on; + gzip_proxied no-store; + limit_except GET { deny all; } + proxy_pass http://127.0.0.1:18765; + proxy_http_version 1.0; + proxy_set_header Host localhost; + proxy_set_header Connection close; + proxy_connect_timeout 3s; + proxy_read_timeout 30s; + proxy_send_timeout 10s; + proxy_max_temp_file_size 0; +} diff --git a/server/reelbox-api.service b/server/reelbox-api.service new file mode 100644 index 0000000..7a06cf1 --- /dev/null +++ b/server/reelbox-api.service @@ -0,0 +1,36 @@ +[Unit] +Description=ReelBox read-only offline Catalogue API +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/reelbox-api +ExecStart=/usr/bin/python3 -B /opt/reelbox-api/catalog_api.py +Environment=REELBOX_CATALOG_DB=/var/lib/reelbox-api/catalog.sqlite +Environment=PYTHONUNBUFFERED=1 +Restart=on-failure +RestartSec=3 +TimeoutStartSec=120 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +RestrictAddressFamilies=AF_INET AF_UNIX +IPAddressDeny=any +IPAddressAllow=localhost +MemoryMax=256M +TasksMax=24 +LimitNOFILE=256 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/server/reelbox-sync.service b/server/reelbox-sync.service new file mode 100644 index 0000000..60dcb54 --- /dev/null +++ b/server/reelbox-sync.service @@ -0,0 +1,40 @@ +[Unit] +Description=Refresh ReelBox catalogue metadata from the original API +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=1d +StartLimitBurst=3 + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/reelbox-api +EnvironmentFile=/etc/reelbox/sync.env +Environment=PYTHONUNBUFFERED=1 +ExecStart=/usr/bin/python3 -B /opt/reelbox-api/catalog_sync_runner.py +TimeoutStartSec=8h +Restart=on-failure +RestartSec=1h +StateDirectory=reelbox-sync +StateDirectoryMode=0700 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths=/var/lib/reelbox-api /var/lib/reelbox-sync +MemoryMax=512M +TasksMax=16 +LimitNOFILE=512 +Nice=10 +IOSchedulingClass=idle +StandardOutput=journal +StandardError=journal diff --git a/server/reelbox-sync.timer b/server/reelbox-sync.timer new file mode 100644 index 0000000..6287401 --- /dev/null +++ b/server/reelbox-sync.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Refresh ReelBox catalogue every three days + +[Timer] +OnBootSec=15min +OnUnitActiveSec=3d +AccuracySec=1min +Unit=reelbox-sync.service + +[Install] +WantedBy=timers.target diff --git a/server/tests/schema.sql b/server/tests/schema.sql new file mode 100644 index 0000000..eb78f63 --- /dev/null +++ b/server/tests/schema.sql @@ -0,0 +1,37 @@ + +PRAGMA foreign_keys=ON; +CREATE TABLE metadata(key TEXT PRIMARY KEY,value_json TEXT NOT NULL); +CREATE TABLE titles( + id INTEGER PRIMARY KEY,type TEXT NOT NULL,title TEXT,description TEXT, + year INTEGER,imdb REAL,rating REAL,duration TEXT,classification TEXT, + image TEXT,cover TEXT,downloadas TEXT,playas TEXT,comments_enabled INTEGER, + raw_json TEXT NOT NULL); +CREATE INDEX titles_type ON titles(type); +CREATE INDEX titles_title ON titles(title COLLATE NOCASE); +CREATE INDEX titles_year ON titles(year); +CREATE TABLE genres(id INTEGER PRIMARY KEY,title TEXT); +CREATE TABLE countries(id INTEGER PRIMARY KEY,title TEXT,image TEXT); +CREATE TABLE title_genres(title_id INTEGER REFERENCES titles(id),genre_id INTEGER REFERENCES genres(id),PRIMARY KEY(title_id,genre_id)); +CREATE TABLE title_countries(title_id INTEGER REFERENCES titles(id),country_id INTEGER REFERENCES countries(id),PRIMARY KEY(title_id,country_id)); +CREATE TABLE movie_sources( + title_id INTEGER NOT NULL REFERENCES titles(id),source_index INTEGER NOT NULL, + source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL, + PRIMARY KEY(title_id,source_index)); +CREATE INDEX movie_sources_title ON movie_sources(title_id); +CREATE TABLE series_fetch_status( + series_id INTEGER PRIMARY KEY REFERENCES titles(id),status TEXT NOT NULL, + season_count INTEGER,source_url TEXT NOT NULL); +CREATE TABLE seasons( + series_id INTEGER NOT NULL REFERENCES titles(id),season_index INTEGER NOT NULL, + id INTEGER,title TEXT,raw_json TEXT NOT NULL,PRIMARY KEY(series_id,season_index)); +CREATE TABLE episodes( + series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL, + id INTEGER,title TEXT,description TEXT,duration TEXT,downloadas TEXT,playas TEXT,raw_json TEXT NOT NULL, + PRIMARY KEY(series_id,season_index,episode_index), + FOREIGN KEY(series_id,season_index) REFERENCES seasons(series_id,season_index)); +CREATE TABLE episode_sources( + series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,source_index INTEGER NOT NULL, + source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL, + PRIMARY KEY(series_id,season_index,episode_index,source_index), + FOREIGN KEY(series_id,season_index,episode_index) REFERENCES episodes(series_id,season_index,episode_index)); +CREATE INDEX episode_sources_series ON episode_sources(series_id); diff --git a/server/tests/test_banners.py b/server/tests/test_banners.py new file mode 100644 index 0000000..5f72b6b --- /dev/null +++ b/server/tests/test_banners.py @@ -0,0 +1,98 @@ +import json +import os +from pathlib import Path +import tempfile +import unittest + +from test_catalog_api import fixture, load_service + + +class BannerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) + self.path = fixture(self.temp.name) + self.api = load_service() + self.catalog = self.api.Catalog(self.path) + + def tearDown(self): + self.catalog.close() + self.temp.cleanup() + + def test_local_config_reloads_safe_bounded_ads_without_admin_endpoint(self): + config = Path(self.temp.name) / 'banners.json' + self.catalog.close() + self.catalog = self.api.Catalog(self.path, banner_config=config) + self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []}) + ad = {'title_en': 'Fixture sponsor', 'title_fa': 'حامی آزمایشی', + 'subtitle_en': 'Test only', 'target_url': 'https://sponsor.example/offer', + 'image_url': 'https://sponsor.example/banner.jpg', 'secret': 'not public'} + config.write_text(json.dumps({'banners': [ad]})) + result = self.catalog.dispatch('/api/banners/')['banners'] + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['title_fa'], ad['title_fa']) + self.assertEqual(result[0]['image_url'], ad['image_url']) + self.assertNotIn('secret', result[0]) + config.write_text('{invalid') + self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []}) + for target in ['http://evil.example/', 'javascript:alert(1)', '//evil.example/', + 'https://a:b@evil.example/', 'https://evil.example:444/', + 'https://evil.example/ bad', 'https://evil.example/\\\\bad', + 'https://evil.example/%0abad', 'https://', + 'https://-bad.example/', 'https://bad..example/', 'https://bad.example./', + 'https://evil.example/%zz', 'https://evil.example/' + 'x' * 2048]: + config.write_text(json.dumps({'banners': [{**ad, 'target_url': target}, ad]})) + self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, target) + for row in [None, True, 5, {}, {**ad, 'title_en': 'x' * 81}, + {**ad, 'subtitle_en': 'x' * 181}, {**ad, 'title_fa': 'bad\u202etext'}, + {**ad, 'image_url': 'http://evil.example/banner.jpg'}]: + config.write_text(json.dumps({'banners': [row, ad]})) + self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, row) + config.write_text(json.dumps({'banners': [ad] * 30})) + self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 5) + config.write_text(json.dumps({'banners': [{}] * 20 + [ad]})) + self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []}) + config.write_text(' ' * 65537) + self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []}) + for value in [None, [], {'banners': {}}, {'banners': []}]: + config.write_text(json.dumps(value)) + self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []}) + for route in ['/api/banners/admin/', '/api/banners/config/']: + with self.assertRaises(self.api.ApiError) as error: + self.catalog.dispatch(route) + self.assertEqual(error.exception.status, 404) + + def test_http_banner_endpoint_is_json_no_store_and_rejects_writes(self): + from http.client import HTTPConnection + import threading + server = self.api.make_server(self.catalog, port=0) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + for method, expected in [('GET', 200), ('HEAD', 200), ('POST', 405), ('PUT', 405)]: + client = HTTPConnection('127.0.0.1', server.server_port, timeout=5) + client.request(method, '/api/banners/') + response = client.getresponse() + self.assertEqual(response.status, expected) + self.assertEqual(response.getheader('Cache-Control'), 'no-store') + self.assertIn('application/json', response.getheader('Content-Type')) + body = response.read() + if method == 'GET': + self.assertEqual(json.loads(body), {'banners': []}) + if method == 'HEAD': + self.assertEqual(body, b'') + client.close() + finally: + server.shutdown() + server.server_close() + worker.join(timeout=5) + + def test_read_only_banner_route_defaults_to_empty_ad_list(self): + self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []}) + self.assertEqual(self.catalog.dispatch('/api/banners'), {'banners': []}) + with self.assertRaises(self.api.ApiError) as error: + self.catalog.dispatch('/api/banners/?url=https://evil.example/') + self.assertEqual(error.exception.status, 400) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/tests/test_catalog_ads.py b/server/tests/test_catalog_ads.py new file mode 100644 index 0000000..6443d7e --- /dev/null +++ b/server/tests/test_catalog_ads.py @@ -0,0 +1,83 @@ +"""Regression coverage for app advertisements disguised as movie records.""" +import json +import sqlite3 +import unittest +from urllib.parse import urlencode + +import test_catalog_api as fixtures + + +class CatalogueAdTests(unittest.TestCase): + setUp = fixtures.CatalogTests.setUp + tearDown = fixtures.CatalogTests.tearDown + open = fixtures.CatalogTests.open + + def test_app_promotions_and_typographic_variants_are_rejected(self): + self.open() + for text in [ + 'نورتون', 'نُوْرتـو\u200cن', 'N O R T O N', + 'آموزش زبان به سبک موسوی‌باکس', + 'آموزش زبان به سبک مووی باکس', + 'آموزش زبان به سبک مُووی‌ باكْس', + 'نسخه جدید هورتون', 'نسخه‌ جدید هُورتـون', + 'اپلیکیشن هورتون', 'نرم افزار هورتون', + ]: + with self.subTest(text=text): + self.assertFalse(self.api.allowed_text(text)) + for text in [ + 'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون', + 'آموزش زبان New Headway', 'The Marriage App', + 'فیلمی درباره یک اپلیکیشن خیالی', 'بازیگران: Alex Horton', + ]: + with self.subTest(text=text): + self.assertTrue(self.api.allowed_text(text)) + + def test_ads_are_absent_from_every_title_surface_and_direct_lookup(self): + with sqlite3.connect(self.path) as db: + original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0]) + records = [ + (66, 'نورتون', ''), + (67, 'آموزش زبان به سبک مووی باکس', ''), + (68, 'آموزش زبان به سبک موسوی‌باکس', ''), + (69, 'نسخه جدید هورتون', ''), + (70, 'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون'), + (71, 'آموزش زبان New Headway', 'مجموعه ویدیویی آموزش مهارت مکالمه'), + (72, 'The Marriage App', 'فیلمی درباره یک اپلیکیشن خیالی'), + (73, 'Language companion', 'اپلیکیشن هورتون'), + ] + for title_id, title, description in records: + raw = {**original, 'id': title_id, 'title': title, 'description': description} + db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)', + (title_id, 'movie', title, description, 2026, 9.9, 5, json.dumps(raw))) + db.execute('INSERT INTO title_genres VALUES (?,2)', (title_id,)) + db.execute('INSERT INTO title_countries VALUES (?,1)', (title_id,)) + catalog = self.open() + ads = {66, 67, 68, 69, 73} + actual = set() + for page in range(4): + for order in ['created', 'imdb', 'rating', 'year', 'title', 'random']: + for path in [f'/api/movie/by/filtres/0/{order}/{page}/public/', + f'/api/poster/by/filtres/2/1/{order}/{page}/public/']: + rows = catalog.dispatch(path) + self.assertFalse(ads & {row['id'] for row in rows}, path) + actual.update(row['id'] for row in rows) + self.assertTrue({70, 71, 72} <= actual) + for group in catalog.dispatch('/api/first/public/')['genres']: + self.assertFalse(ads & {row['id'] for row in group['posters']}) + for action in ['best-rand-top', 'best-rand-year']: + rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action})) + self.assertFalse(ads & {row['id'] for row in rows}) + for query in ['نورتون', 'هورتون', 'آموزش زبان', 'موسوی', 'مووی', 'Language companion']: + rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': 'search-movie', 'q': query})) + self.assertFalse(ads & {row['id'] for row in rows}, query) + rows = catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1') + self.assertFalse(ads & {row['id'] for row in rows}) + for title_id in ads: + for path in [f'/api/movie/by/{title_id}/public/', f'/api/season/by/serie/{title_id}/public/']: + with self.assertRaises(self.api.ApiError) as error: + catalog.dispatch(path) + self.assertEqual(error.exception.status, 404) + for title_id in [70, 71, 72]: + self.assertEqual(catalog.dispatch(f'/api/movie/by/{title_id}/public/')['id'], title_id) + detail = catalog.dispatch('/api/movie/by/1/public/') + self.assertEqual(detail['sources'], original['sources']) diff --git a/server/tests/test_catalog_announcements.py b/server/tests/test_catalog_announcements.py new file mode 100644 index 0000000..59a3318 --- /dev/null +++ b/server/tests/test_catalog_announcements.py @@ -0,0 +1,147 @@ +"""Upstream notices are not catalogue films, including after later scrapes.""" +from contextlib import closing +import json +from pathlib import Path +import sqlite3 +import tempfile +from typing import Any +import unittest +from urllib.parse import urlencode + +import test_catalog_api as fixtures +from test_catalog_api import load_service +from test_catalog_sync import FakeUpstream, load_sync + + +class AnnouncementPolicyTests(unittest.TestCase): + def test_generic_notice_labels_are_blocked_without_hiding_real_notice_films(self): + api = load_service() + for text in [ + 'اطلاعیه جدید', 'اطلاعیه مهم ', 'اطلاعیه خیلی مهم', + '📢 **اطلاعـیّهٔ جدید**', 'اطلاع يه جديد', 'اطلاع‌رسانی جدید', + 'اعلان جدید', 'اعلان مهم شماره ۲', + 'به روز رسانی جدید', 'به‌روزرسانی برنامه', 'بروز رسانی نسخه ویندوز', + 'آپدیت جدید برنامه', 'New announcement', 'IMPORTANT NOTICE!', + 'Urgent announcement: service changes', 'App update', + ]: + with self.subTest(blocked=text): + self.assertFalse(api.allowed_text(text)) + for text in [ + 'Red Notice', 'SAS: Red Notice', 'Burn Notice', 'Notice to Quit', + 'Two Weeks Notice', 'The Announcement', 'اعلان قرمز', 'Upgrade', + 'News of the World', 'نام پارسی فیلم: اعلان قرمز', + 'A detective reads an important notice before leaving town.', + 'در این فیلم یک اطلاعیه جدید، زندگی شخصیت اصلی را تغییر می‌دهد.', + ]: + with self.subTest(allowed=text): + self.assertTrue(api.allowed_text(text)) + + +class AnnouncementSurfaceTests(unittest.TestCase): + path: Path + temp: tempfile.TemporaryDirectory + api: Any + setUp = fixtures.CatalogTests.setUp + tearDown = fixtures.CatalogTests.tearDown + open = fixtures.CatalogTests.open + + def test_notices_are_absent_from_all_catalogue_routes_and_direct_lookup(self): + bad = {70, 71, 72, 73, 74} + with closing(sqlite3.connect(self.path)) as db: + original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0]) + records = [ + (70, 'movie', 'اطلاعیه جدید', ''), + (71, 'movie', 'اطلاعیه خیلی مهم', ''), + (72, 'movie', 'به روز رسانی جدید', ''), + (73, 'movie', 'Service changes', 'Important announcement: app changes'), + (74, 'serie', 'اعلان جدید', ''), + (75, 'movie', 'Red Notice', 'نام پارسی فیلم: اعلان قرمز'), + (76, 'serie', 'Burn Notice', 'An ordinary spy series'), + ] + for ident, kind, title, description in records: + raw = {**original, 'id': ident, 'type': kind, 'title': title, 'description': description} + db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)', + (ident, kind, title, description, 2026, 9.9, 5, json.dumps(raw))) + db.execute('INSERT INTO title_genres VALUES (?,2)', (ident,)) + db.execute('INSERT INTO title_countries VALUES (?,1)', (ident,)) + db.commit() + catalog = self.open() + found = set() + for kind in ('movie', 'serie', 'poster'): + for order in ('created', 'updated', 'imdb', 'rating', 'year', 'title', 'random'): + for page in range(4): + for path in (f'/api/{kind}/by/filtres/0/{order}/{page}/public/', + f'/api/{kind}/by/filtres/2/1/{order}/{page}/public/'): + ids = {row['id'] for row in catalog.dispatch(path)} + self.assertFalse(bad & ids, path) + found.update(ids) + self.assertTrue({75, 76} <= found) + for group in catalog.dispatch('/api/first/public/')['genres']: + self.assertFalse(bad & {row['id'] for row in group['posters']}) + for action in ('best-rand-top', 'best-rand-year'): + self.assertFalse(bad & {row['id'] for row in catalog.dispatch( + '/api-user/newapi/like.php?' + urlencode({'action': action}))}) + for action in ('search-movie', 'search-movie-en'): + for query in ('اطلاعیه', 'به روز', 'announcement', 'Notice'): + rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action, 'q': query})) + self.assertFalse(bad & {row['id'] for row in rows}) + self.assertFalse(bad & {row['id'] for row in catalog.dispatch( + '/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')}) + for ident in bad: + for path in (f'/api/movie/by/{ident}/public/', f'/api/season/by/serie/{ident}/public/'): + with self.assertRaises(self.api.ApiError) as error: + catalog.dispatch(path) + self.assertEqual(error.exception.status, 404) + self.assertEqual(catalog.dispatch('/api/movie/by/75/public/')['title'], 'Red Notice') + self.assertEqual(catalog.dispatch('/api/movie/by/76/public/')['title'], 'Burn Notice') + + def test_repeated_scrapes_keep_old_and_new_notice_ids_out_of_the_api(self): + # Old raw rows stay for snapshot/source preservation, but are never served. + with closing(sqlite3.connect(self.path)) as db: + item = {'id': 90, 'type': 'movie', 'title': 'اطلاعیه مهم', 'description': ''} + db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)', + (90, 'movie', item['title'], '', json.dumps(item))) + db.commit() + source_bytes = self.path.read_bytes() + source = self.path + sync = load_sync() + for run, offset in enumerate((0, 100), 1): + new_notice, series_notice, detail_notice = 91 + offset, 92 + offset, 93 + offset + rows = [ + {'id': 90, 'type': 'movie', 'title': 'اطلاعیه جدید'}, + {'id': new_notice, 'type': 'movie', 'title': '📢 اطلاعیه جدید'}, + {'id': detail_notice, 'type': 'movie', 'title': 'Looks like a film'}, + {'id': 94, 'type': 'movie', 'title': 'Red Notice'}, + ] + upstream = FakeUpstream({ + 'movie/by/filtres/0/created/0': rows, + f'movie/by/{detail_notice}': {'id': detail_notice, 'type': 'movie', 'title': 'به روز رسانی جدید'}, + 'movie/by/94': {'id': 94, 'type': 'movie', 'title': 'Red Notice', 'sources': []}, + 'serie/by/filtres/0/created/0': [ + {'id': series_notice, 'type': 'serie', 'title': 'Important announcement'}], + }) + stage = Path(self.temp.name) / f'scrape-{run}.sqlite' + sync.synchronize(source, stage, upstream) + self.assertNotIn(f'movie/by/{new_notice}', upstream.calls) + self.assertNotIn('movie/by/90', upstream.calls) + self.assertNotIn(f'season/by/serie/{series_notice}', upstream.calls) + with closing(sqlite3.connect(stage)) as db: + self.assertEqual(db.execute('SELECT COUNT(*) FROM titles WHERE id IN (?,?,?)', + (new_notice, series_notice, detail_notice)).fetchone()[0], 0) + self.assertEqual(db.execute('SELECT title FROM titles WHERE id=90').fetchone()[0], 'اطلاعیه مهم') + api = load_service() + catalog = api.Catalog(stage) + try: + for ident in (90, new_notice, series_notice, detail_notice): + with self.assertRaises(api.ApiError): + catalog.dispatch(f'/api/movie/by/{ident}/public/') + self.assertEqual(catalog.dispatch('/api/movie/by/94/public/')['title'], 'Red Notice') + self.assertEqual(catalog.dispatch('/api/season/by/serie/2/public/')[0]['episodes'][0]['id'], 501) + finally: + catalog.close() + source = stage + self.assertEqual(self.path.read_bytes(), source_bytes) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/tests/test_catalog_api.py b/server/tests/test_catalog_api.py new file mode 100644 index 0000000..c5ebdd4 --- /dev/null +++ b/server/tests/test_catalog_api.py @@ -0,0 +1,246 @@ +import importlib.util +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +SERVER = Path(__file__).resolve().parents[1] + + +def load_service(): + spec = importlib.util.spec_from_file_location('catalog_api', SERVER / 'catalog_api.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def fixture(directory): + path = Path(directory) / 'catalog.sqlite' + con = sqlite3.connect(path) + con.executescript((SERVER / 'tests' / 'schema.sql').read_text()) + metadata = {'built_at_utc': '2026-11-02T07:00:18+00:00', + 'provenance': 'Realistic test snapshot', + 'catalog_pagination_complete': True, 'series_episode_fetch_complete': True} + con.executemany('INSERT INTO metadata VALUES (?,?)', + [(k, json.dumps(v)) for k, v in metadata.items()]) + con.executemany('INSERT INTO genres VALUES (?,?)', [(1, 'Horror'), (2, 'Action'), (32, 'Top 250'), (48, 'Live')]) + con.execute('INSERT INTO countries VALUES (1,?,?)', ('America', 'http://art.test/a%2Fb.jpg?token=Ab+Cd')) + for i in range(1, 66): + kind = 'serie' if i == 2 else 'movie' + title = 'Alpha' if i == 1 else 'Warrior' if i == 2 else 'Film ' + str(i) + if i == 63: + title = 'نسخه جدید ملودی باکس' + if i == 64: + kind = 'channel' + if i == 65: + title = 'Telegram @channelname' + item = {'id': i, 'type': kind, 'title': title, 'description': 'Actor Example بازیگران', + 'year': 2020 + i % 7, 'imdb': i / 10, 'rating': 4.1, + 'image': 'http://art.test/a%2Fb.jpg?token=Ab+Cd', + 'cover': 'https://art.test/a b.jpg', 'comment': True, + 'genres': [{'id': 2, 'title': 'Action'}], 'country': [{'id': 1, 'title': 'America'}]} + if kind == 'movie': + source = {'id': 100 + i, 'type': 'mkv', 'quality': '720', + 'url': 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment'} + item['sources'] = [source] + con.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)', + (i, kind, title, item['description'], item['year'], item['imdb'], item['rating'], json.dumps(item))) + if kind == 'movie': + con.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?)', + (i, 0, source['id'], '720', 'mkv', source['url'], json.dumps(source))) + con.execute('INSERT INTO title_genres VALUES (?,2)', (i,)) + con.execute('INSERT INTO title_countries VALUES (?,1)', (i,)) + con.execute('INSERT INTO title_genres VALUES (1,32)') + episode = {'id': 501, 'title': 'Episode 1', 'sources': [{'id': 201, 'type': 'mkv', + 'quality': None, 'url': 'http://media.test/a%20b.mkv?signature=%2f+Ab'}]} + season = {'id': 1001, 'title': 'Season 1', 'episodes': [episode]} + con.execute('INSERT INTO seasons VALUES (2,0,1001,?,?)', ('Season 1', json.dumps(season))) + con.execute('INSERT INTO episodes(series_id,season_index,episode_index,id,title,raw_json) VALUES (2,0,0,501,?,?)', + ('Episode 1', json.dumps(episode))) + con.execute('INSERT INTO episode_sources VALUES (2,0,0,0,201,NULL,?,?,?)', + ('mkv', episode['sources'][0]['url'], json.dumps(episode['sources'][0]))) + con.execute('INSERT INTO series_fetch_status VALUES (2,?,1,?)', ('ok', 'https://old.test/api/season/2')) + con.commit() + con.close() + return path + + +class CatalogTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) + self.path = fixture(self.temp.name) + + def tearDown(self): + if hasattr(self, 'catalog'): + self.catalog.close() + self.temp.cleanup() + + def open(self): + self.api = load_service() + self.catalog = self.api.Catalog(self.path) + return self.catalog + + def test_health_is_real_snapshot_counts_and_freshness(self): + catalog = self.open() + health = catalog.dispatch('/api/health') + self.assertEqual(health['status'], 'ok') + self.assertEqual(health['counts']['titles'], 65) + self.assertEqual(health['counts']['episodes'], 1) + self.assertEqual(health['counts']['visible_titles'], 62) + self.assertEqual(health['snapshot_built_at_utc'], '2026-11-02T07:00:18+00:00') + self.assertEqual(health['provenance'], 'Realistic test snapshot') + self.assertFalse(health['media_proxy']) + self.assertTrue(health['offline']) + self.assertGreaterEqual(health['snapshot_age_seconds'], 0) + with self.assertRaises(sqlite3.OperationalError): + catalog.db.execute('DELETE FROM titles') + def test_catalog_filters_pages_and_original_media_links(self): + catalog = self.open() + first = catalog.dispatch('/api/movie/by/filtres/0/created/0/public/') + second = catalog.dispatch('/api/movie/by/filtres/0/created/1/public/') + self.assertEqual(len(first), 30) + self.assertEqual(len(second), 30) + self.assertFalse({r['id'] for r in first} & {r['id'] for r in second}) + self.assertTrue(all(r['id'] <= 62 and r['type'] == 'movie' for r in first + second)) + self.assertEqual([r['id'] for r in catalog.dispatch('/api/movie/by/filtres/0/created/2/public/')], [1]) + self.assertEqual(catalog.dispatch('/api/movie/by/filtres/0/created/3/public/'), []) + self.assertEqual([r['id'] for r in catalog.dispatch('/api/poster/by/filtres/32/1/imdb/0/public/')], [1]) + self.assertEqual([r['id'] for r in catalog.dispatch('/api/serie/by/filtres/0/year/0/public/')], [2]) + film = catalog.dispatch('/api/movie/by/1/public/') + original = json.loads(catalog.db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0]) + self.assertEqual(film['sources'], original['sources']) + self.assertEqual(film['image'], original['image']) + self.assertNotIn('comment', film) + seasons = catalog.dispatch('/api/season/by/serie/2/public/') + self.assertEqual(seasons[0]['episodes'][0]['sources'][0]['url'], 'http://media.test/a%20b.mkv?signature=%2f+Ab') + self.assertEqual(catalog.dispatch('/api/genre/all/public/')[0]['id'], 1) + self.assertNotIn(48, [r['id'] for r in catalog.dispatch('/api/genre/all/public/')]) + self.assertEqual(catalog.dispatch('/api/country/all/public/')[0]['title'], 'America') + for title_id in (63, 64, 65, 999): + with self.assertRaises(self.api.ApiError) as error: + catalog.dispatch(f'/api/movie/by/{title_id}/public/') + self.assertEqual(error.exception.status, 404) + def test_home_search_recommendations_curated_and_unexported_metadata(self): + catalog = self.open() + home = catalog.dispatch('/api/first/public/') + self.assertTrue(home['genres'][0]['posters']) + self.assertEqual(home['actors'], []) + self.assertEqual(home['countries'][0]['id'], 1) + search = catalog.dispatch('/api-user/newapi/like.php?action=search-movie-en&q=alpha&pageno=1') + self.assertEqual([r['id'] for r in search], [1]) + self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=no-match&pageno=1'), []) + self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=%27%20OR%201%3D1--'), []) + self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-top&pageno=1')[0]['id'], 62) + self.assertTrue(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-year&pageno=1')) + curated = catalog.dispatch('/api-user/newapi/pishnahad.php') + self.assertTrue(curated) + self.assertEqual(len(curated[0]['title']), len(curated[0]['link'])) + self.assertTrue(catalog.dispatch(curated[0]['link'][0] + '1')) + self.assertEqual([r['id'] for r in catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=32&pageno=1')], [1]) + for target in ['/api-user/newapi/like.php?action=all-actor&pageno=1', + '/api-user/newapi/like.php?action=colection&pageno=1', + '/api-user/filmahang/star.php?key=public&action=starsearch&q=Example&pageno=1', + '/api-user/filmahang/star.php?key=public&action=mazmon&pageno=1', + '/api-user/filmahang/star.php?key=public&action=share&pageno=1', + '/api/role/by/poster/1/public/', '/api/movie/by/actor/12/public/', + '/api/subtitles/by/movie/1/public/', '/api/subtitles/by/episode/501/public/']: + self.assertEqual(catalog.dispatch(target), [], target) + def test_rejects_proxy_comments_traversal_and_unbounded_inputs(self): + catalog = self.open() + for target in ['/api/proxy?url=http://169.254.169.254/', '/api/download/movie.mkv', + '/api/comments/by/poster/1/public/', '/api/../catalog.sqlite', + '/api/%2e%2e/catalog.sqlite', '//evil.test/api/health', + '/api/movie/by/1/public/?url=https://media.test/movie.mkv', + '/api-user/newapi/like.php?action=comments', + '/api-user/newapi/like.php?action=search-movie&q=a&q=b', + '/api-user/newapi/like.php?action=search-movie&q=' + 'a' * 201, + '/api-user/newapi/like.php?action=search-movie&pageno=-1', + '/api-user/newapi/like.php?action=search-movie&pageno=100002', + '/api/movie/by/filtres/0/views/0/public/']: + with self.assertRaises(self.api.ApiError, msg=target): + catalog.dispatch(target) + + def test_raw_controls_and_malformed_urls_are_rejected_before_parsing(self): + catalog = self.open() + for target in ['/api/\thealth', '\n/api/health', '/api/health\r', 'http://[', + '/api-user/newapi/like.php?action=search-movie&q=a\tb']: + with self.subTest(target=repr(target)): + with self.assertRaises(self.api.ApiError) as error: + catalog.dispatch(target) + self.assertEqual(error.exception.status, 400) + + def test_real_http_is_read_only_bounded_and_returns_direct_source_urls(self): + import threading + from urllib.request import Request, urlopen + from urllib.error import HTTPError + catalog = self.open() + server = self.api.make_server(catalog, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + origin = 'http://127.0.0.1:' + str(server.server_port) + try: + with urlopen(origin + '/api/movie/by/1/public/', timeout=3) as response: + detail = json.load(response) + self.assertEqual(response.headers['Content-Type'], 'application/json; charset=utf-8') + self.assertNotIn('Server', response.headers) + self.assertEqual(detail['sources'][0]['url'], 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment') + for method in ['POST', 'PUT', 'DELETE', 'PATCH', 'CONNECT']: + with self.assertRaises(HTTPError) as error: + urlopen(Request(origin + '/api/health', method=method), timeout=3) + self.assertEqual(error.exception.code, 405) + error.exception.close() + with self.assertRaises(HTTPError) as error: + urlopen(origin + '/api/proxy?url=https://media.test/a.mkv', timeout=3) + self.assertIn(error.exception.code, (400, 404)) + error.exception.close() + with urlopen(Request(origin + '/api/health', method='HEAD'), timeout=3) as response: + self.assertEqual(response.status, 200) + self.assertEqual(response.read(), b'') + finally: + server.shutdown() + server.server_close() + thread.join(3) + def test_worker_saturation_rejects_extra_connections_and_releases_slots(self): + import socket + import threading + import time + catalog = self.open() + server = self.api.make_server(catalog, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + sockets = [] + def wait_slots(value): + deadline = time.monotonic() + 4 + while server.slots._value != value and time.monotonic() < deadline: + time.sleep(.01) + self.assertEqual(server.slots._value, value) + try: + for _ in range(16): + sock = socket.create_connection(server.server_address, timeout=2) + sock.sendall(b'GET /api/health HTTP/1.0\r\n') + sockets.append(sock) + wait_slots(0) + with socket.create_connection(server.server_address, timeout=2) as extra: + try: + self.assertEqual(extra.recv(1), b'') + except ConnectionResetError: + pass + for sock in sockets: + sock.close() + sockets.clear() + wait_slots(16) + with socket.create_connection(server.server_address, timeout=2) as recovered: + recovered.sendall(b'GET /api/health HTTP/1.0\r\n\r\n') + self.assertIn(b'200', recovered.recv(100)) + finally: + for sock in sockets: + sock.close() + server.shutdown() + server.server_close() + thread.join(3) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/tests/test_catalog_performance.py b/server/tests/test_catalog_performance.py new file mode 100644 index 0000000..edcb60f --- /dev/null +++ b/server/tests/test_catalog_performance.py @@ -0,0 +1,110 @@ +"""Operation-count regressions; timings are recorded separately, never asserted.""" +import json +import threading +import unittest +from unittest.mock import patch +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +import test_catalog_api as fixtures + + +class CatalogPerformanceTests(unittest.TestCase): + setUp = fixtures.CatalogTests.setUp + tearDown = fixtures.CatalogTests.tearDown + open = fixtures.CatalogTests.open + def http_server(self, catalog): + server = self.api.make_server(catalog, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def stop(): + server.shutdown() + server.server_close() + thread.join(3) + self.addCleanup(stop) + return 'http://127.0.0.1:' + str(server.server_port) + + def test_repeated_snapshot_http_read_does_not_query_or_serialize_again(self): + catalog = self.open() + origin = self.http_server(catalog) + with urlopen(origin + '/api/first/public/', timeout=3) as response: + first = response.read() + queries = [] + catalog.db.set_trace_callback(queries.append) + with patch.object(self.api.json, 'dumps', wraps=json.dumps) as encode: + with urlopen(origin + '/api/first/public/', timeout=3) as response: + self.assertEqual(response.read(), first) + self.assertEqual(queries, [], 'Warm snapshot HTTP responses must reuse encoded bytes') + self.assertEqual(encode.call_count, 0) + + def test_cache_bounds_lru_and_large_response_bypass(self): + catalog = self.open() + catalog.RESPONSE_CACHE_ENTRIES = 2 + one = '/api/movie/by/1/public/' + two = '/api/movie/by/2/public/' + three = '/api/movie/by/3/public/' + first = catalog.response(one) + catalog.response(two) + self.assertIs(catalog.response(one), first) + catalog.response(three) + self.assertEqual(list(catalog._responses), [one, three]) + self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES) + catalog.RESPONSE_CACHE_ITEM_BYTES = 1 + catalog.response(two) + self.assertNotIn(two, catalog._responses) + # Byte budget, not just number of entries, forces eviction. + catalog.RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024 + catalog.RESPONSE_CACHE_BYTES = len(catalog.response(three)) + len(three) + 1 + catalog.response(two) + self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES) + self.assertEqual(len(catalog._responses), 1) + + def test_dynamic_responses_errors_and_queries_are_never_retained(self): + catalog = self.open() + with patch.object(catalog, 'health', side_effect=[{'age': 1}, {'age': 2}]): + self.assertNotEqual(catalog.response('/api/health'), catalog.response('/api/health')) + with patch.object(self.api, 'load_banners', side_effect=[{'banners': [1]}, {'banners': [2]}]): + self.assertNotEqual(catalog.response('/api/banners'), catalog.response('/api/banners')) + catalog.response('/api-user/newapi/like.php?action=search-movie&q=Alpha') + for _ in range(2): + with self.assertRaises(self.api.ApiError): + catalog.response('/api/movie/by/999/public/') + self.assertEqual(list(catalog._responses), []) + + def test_warm_head_reuses_get_bytes_and_body_requests_still_fail(self): + catalog = self.open() + origin = self.http_server(catalog) + with urlopen(origin + '/api/first/public/', timeout=3) as response: + first = response.read() + with urlopen(Request(origin + '/api/first/public/', method='HEAD'), timeout=3) as response: + self.assertEqual(response.read(), b'') + self.assertEqual(int(response.headers['Content-Length']), len(first)) + with self.assertRaises(HTTPError) as error: + urlopen(Request(origin + '/api/first/public/', method='GET', data=b'x'), timeout=3) + self.assertEqual(error.exception.code, 400) + error.exception.close() + + def test_normalized_search_matches_fallback_with_unicode_and_pagination(self): + catalog = self.open() + indexed = catalog.search_text + self.assertLessEqual(catalog.search_index_bytes, 64 * 1024 * 1024) + for query in ['Actor', 'بازیگران', 'بازيگران', 'ALPHA', 'not-present', 'Film 4']: + for page in [0, 1, 2]: + catalog.search_text = indexed + expected = catalog.titles(query=query, page=page) + catalog.search_text = {} + self.assertEqual(catalog.titles(query=query, page=page), expected) + catalog.search_text = indexed + + def test_search_normalizes_query_not_every_snapshot_row(self): + catalog = self.open() + with patch.object(self.api, 'normal', wraps=self.api.normal) as normalize: + catalog.db.create_function('normal', 1, normalize, deterministic=True) + result = catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=ACTOR&pageno=1') + self.assertEqual([row['id'] for row in result], list(range(62, 32, -1))) + self.assertLessEqual(normalize.call_count, 2, + 'Immutable title text must not be normalized on every search') + + +if __name__ == '__main__': + unittest.main() diff --git a/server/tests/test_catalog_sync.py b/server/tests/test_catalog_sync.py new file mode 100644 index 0000000..8bafcbe --- /dev/null +++ b/server/tests/test_catalog_sync.py @@ -0,0 +1,463 @@ +"""Synchronizer tests use the exported schema, never the upstream service.""" +import copy +from contextlib import closing +import importlib.util +import io +import json +import os +import sys +from pathlib import Path +import sqlite3 +import ssl +import tempfile +import unittest +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +from test_catalog_api import SERVER, fixture, load_service + + +def load_sync(): + spec = importlib.util.spec_from_file_location('catalog_sync', SERVER / 'catalog_sync.py') + module = importlib.util.module_from_spec(spec) + with patch.object(sys, 'path', [str(SERVER)] + sys.path): + spec.loader.exec_module(module) + return module + + +class Response(io.BytesIO): + status = 200 + headers = {} + + def __init__(self, payload=b'[]', url=None, status=200): + super().__init__(payload) + self.url, self.status = url, status + + def geturl(self): + return self.url + + +class QueueOpener: + def __init__(self, *responses): + self.responses = list(responses) + self.calls = [] + + def open(self, request, timeout): + self.calls.append((request.full_url, request.get_method(), timeout)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + if response.url is None: + response.url = request.full_url + return response + + +class UpstreamTests(unittest.TestCase): + def test_exact_fixed_origin_metadata_urls_and_quoted_key(self): + sync = load_sync() + opener = QueueOpener(*(Response(b'{}' if p.startswith('movie/by/3') else b'[]') for p in + ['genre/all', 'country/all', 'movie/by/35721', + 'movie/by/filtres/0/created/0', 'serie/by/filtres/0/created/12', + 'season/by/serie/35716'])) + client = sync.Upstream('test secret/+#?', interval=0, opener=opener) + paths = ['genre/all', 'country/all', 'movie/by/35721', 'movie/by/filtres/0/created/0', + 'serie/by/filtres/0/created/12', 'season/by/serie/35716'] + for path in paths: + client.get(path) + self.assertEqual(opener.calls, [('https://ninwinios.info/api/' + p + + '/test%20secret%2F%2B%23%3F/', 'GET', 30) for p in paths]) + self.assertEqual(client.requests_count, 6) + for bad in ['https://evil.test/api/genre/all', '//evil.test/a', '/genre/all', + 'genre/all/', 'genre/../all', 'genre/%2e%2e/all', 'genre/all?url=x', + 'genre/all#x', 'movie/by/1/surprise', 'download/movie.mkv', + 'movie/by/1\n', 'movie/by/0', 'movie/by/-1', 'movie/by/filtres/0/views/0']: + with self.subTest(path=repr(bad)), self.assertRaises(ValueError): + client.get(bad) + self.assertEqual(client.requests_count, 6) + + def test_real_upstream_legacy_html_label_still_requires_valid_json(self): + sync = load_sync() + response = Response(b'[{"id":1,"title":"Action"}]') + response.headers = {'Content-Type': 'text/html; charset=UTF-8'} + client = sync.Upstream('k', interval=0, opener=QueueOpener(response)) + self.assertEqual(client.get('genre/all'), [{'id': 1, 'title': 'Action'}]) + html = Response(b'Service unavailable') + html.headers = {'Content-Type': 'text/html; charset=UTF-8'} + opener = QueueOpener(html) + with self.assertRaises(sync.SyncError): + sync.Upstream('k', interval=0, opener=opener).get('genre/all') + self.assertEqual(len(opener.calls), 1) + + def test_wrong_content_type_and_truncated_length_are_not_retried(self): + sync = load_sync() + for headers in ({'Content-Type': 'video/mp4'}, {'Content-Type': 'image/jpeg'}, + {'Content-Length': '999'}, {'Content-Length': 'bad'}, + {'Content-Length': str(32 * 1024 * 1024 + 1)}): + response = Response(b'[]') + response.headers = headers + opener = QueueOpener(response) + with self.subTest(headers=headers), self.assertRaises(sync.SyncError): + sync.Upstream('k', interval=0, opener=opener).get('genre/all') + self.assertEqual(len(opener.calls), 1) + + def test_bounded_retry_and_rate_limit(self): + sync = load_sync() + for status in (429, 503): + error = HTTPError('https://secret.test/key', status, 'private key', {}, None) + opener = QueueOpener(error, Response(b'[]')) + client = sync.Upstream('private-key', interval=.25, opener=opener) + with patch.object(sync.time, 'sleep') as sleep: + self.assertEqual(client.get('genre/all'), []) + self.assertGreaterEqual(sleep.call_count, 1) + self.assertEqual(client.requests_count, 2) + opener = QueueOpener(URLError('private URL'), Response(b'[]')) + with patch.object(sync.time, 'sleep'): + self.assertEqual(sync.Upstream('k', interval=0, opener=opener).get('genre/all'), []) + opener = QueueOpener(Response(), Response()) + client = sync.Upstream('k', interval=.25, opener=opener) + with patch.object(sync.time, 'monotonic', return_value=100), patch.object(sync.time, 'sleep') as sleep: + client.get('genre/all') + client.get('country/all') + sleep.assert_called_once_with(.25) + + def test_auth_cert_redirect_json_and_size_fail_closed_without_secrets(self): + sync = load_sync() + for failure in [HTTPError('https://secret.test/private-key', code, 'private-key', {}, None) + for code in (301, 302, 307, 308, 401, 403, 404)] + [ + URLError(ssl.SSLCertVerificationError('private-key')), + ssl.SSLCertVerificationError('private-key')]: + opener = QueueOpener(failure) + client = sync.Upstream('private-key', interval=0, opener=opener) + with self.assertRaises(sync.SyncError) as error: + client.get('genre/all') + self.assertNotIn('private-key', str(error.exception)) + self.assertNotIn('https:', str(error.exception)) + self.assertEqual(len(opener.calls), 1) + for response in [Response(b'not JSON'), Response(b'NaN'), Response(b'{}'), + Response(b'null'), Response(b'[]', url='https://evil.test/video.mkv'), + Response(b'[]', status=302), Response(b'[' * (32 * 1024 * 1024 + 1))]: + opener = QueueOpener(response) + with self.assertRaises(sync.SyncError): + sync.Upstream('private-key', interval=0, opener=opener).get('genre/all') + self.assertEqual(len(opener.calls), 1) + client = sync.Upstream('k', interval=0) + from urllib.request import HTTPRedirectHandler + handlers = [h for h in client._opener.handlers if isinstance(h, HTTPRedirectHandler)] + self.assertEqual(len(handlers), 1) + with self.assertRaises(sync.SyncError): + handlers[0].redirect_request(None, None, 302, 'moved', {}, 'https://evil.test/video') + + def test_retry_exhaustion_and_configuration_are_bounded(self): + sync = load_sync() + opener = QueueOpener(*(HTTPError('https://secret/key', 503, 'key', {}, None) for _ in range(3))) + with patch.object(sync.time, 'sleep'), self.assertRaises(sync.SyncError): + sync.Upstream('k', interval=0, opener=opener).get('genre/all') + self.assertEqual(len(opener.calls), 3) + for kwargs in ({'attempts': 0}, {'attempts': 11}, {'interval': -1}, {'timeout': 0}, + {'interval': float('nan')}, {'timeout': float('inf')}): + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + sync.Upstream('k', **kwargs) + + +class FakeUpstream: + def __init__(self, routes=None): + self.routes = {'genre/all': [], 'country/all': [], + 'movie/by/filtres/0/created/0': [], + 'movie/by/filtres/0/created/1': [], + 'serie/by/filtres/0/created/0': [], + 'serie/by/filtres/0/created/1': [], 'season/by/serie/2': []} + self.routes.update(routes or {}) + self.calls = [] + self.requests_count = 0 + + def get(self, path): + self.calls.append(path) + self.requests_count += 1 + if path not in self.routes: + raise AssertionError('Unexpected metadata path: ' + path) + value = self.routes[path] + if isinstance(value, Exception): + raise value + return copy.deepcopy(value) + + +class SynchronizeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) + self.source = fixture(self.temp.name) + self.stage = Path(self.temp.name) / 'stage.sqlite' + self.sync = load_sync() + + def tearDown(self): + self.temp.cleanup() + + def rows(self, path, sql, args=()): + with closing(sqlite3.connect(path)) as db: + return db.execute(sql, args).fetchall() + + def metadata(self, path): + return {key: json.loads(value) for key, value in self.rows(path, 'SELECT * FROM metadata')} + + def test_all_known_visible_series_refresh_and_series_samples_are_bounded(self): + with closing(sqlite3.connect(self.source)) as db: + for title_id, title, description in [(90, 'An older series', 'Normal description'), + (91, 'Telegram @channelname', ''), + (92, 'Disguised advertisement', 'ملودی باکس')]: + item = {'id': title_id, 'type': 'serie', 'title': title, 'description': description} + db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)', + (title_id, 'serie', title, description, json.dumps(item))) + db.commit() + upstream = FakeUpstream({'season/by/serie/90': [{'id': 900, 'title': 'Season 1', 'episodes': [ + {'id': 901, 'title': 'New episode on an old series'}]}]}) + report = self.sync.synchronize(self.source, self.stage, upstream) + self.assertEqual(report['added']['episodes'], 1) + self.assertIn('season/by/serie/2', upstream.calls) + self.assertIn('season/by/serie/90', upstream.calls) + self.assertNotIn('season/by/serie/91', upstream.calls) + self.assertNotIn('season/by/serie/92', upstream.calls) + self.assertEqual(report['counts']['series'], 4) + self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles WHERE id IN (91,92)'), [(2,)]) + self.stage.unlink() + records = [{'id': i, 'type': 'serie', 'title': 'New Series ' + str(i)} for i in range(100, 201)] + routes = {'serie/by/filtres/0/created/0': records, 'season/by/serie/90': []} + routes.update({f'season/by/serie/{item["id"]}': [] for item in records}) + report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes)) + self.assertEqual(report['updated_series_count'], 101) + self.assertEqual(len(report['updated_series_ids']), 100) + self.assertEqual(report['updated_series_ids'], list(range(100, 200))) + + def test_progress_failure_discards_candidate_and_missing_source_is_not_created(self): + original = self.source.read_bytes() + def reject(_event): + raise RuntimeError('private-key') + with self.assertRaises(self.sync.SyncError) as error: + self.sync.synchronize(self.source, self.stage, FakeUpstream(), progress=reject) + self.assertNotIn('private-key', str(error.exception)) + self.assertEqual(self.source.read_bytes(), original) + self.assertFalse(self.stage.exists()) + missing = Path(self.temp.name) / 'missing.sqlite' + with self.assertRaises(self.sync.SyncError): + self.sync.synchronize(missing, self.stage, FakeUpstream()) + self.assertFalse(missing.exists()) + self.assertFalse(self.stage.exists()) + + def test_malformed_metadata_discards_candidate_not_source(self): + original = self.source.read_bytes() + cases = [ + {'genre/all': {'error': 'not a list'}}, + {'country/all': [{'error': 'bad metadata'}]}, + {'movie/by/filtres/0/created/0': {'error': 'failed'}}, + {'serie/by/filtres/0/created/0': [None]}, + {'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie'}]}, + {'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie', 'title': {'bad': 'title'}}]}, + {'movie/by/filtres/0/created/0': [{'id': True, 'type': 'movie', 'title': 'Bad'}]}, + {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha', 'sources': None}]}, + {'season/by/serie/2': {'error': 'not seasons'}}, + {'season/by/serie/2': [{'error': 'not a season'}]}, + {'season/by/serie/2': [{'id': 1001, 'episodes': {'error': 'not episodes'}}]}, + {'season/by/serie/2': [{'id': 1001, 'episodes': [{'error': 'not an episode'}]}]}, + {'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{}]}]}]}, + {'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{'id': 201, 'url': []}]}]}]}, + {'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': 'not sources'}]}]}, + {'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501}, {'id': 501}]}]}, + {'season/by/serie/2': ValueError('https://private.test/private-key')}, + ] + for routes in cases: + with self.subTest(routes=routes): + with self.assertRaises(self.sync.SyncError) as error: + self.sync.synchronize(self.source, self.stage, FakeUpstream(routes)) + self.assertNotIn('private-key', str(error.exception)) + self.assertFalse(self.stage.exists()) + self.assertEqual(self.source.read_bytes(), original) + + def test_429_503_exhaustion_rolls_back_staged_writes(self): + original = self.source.read_bytes() + for status in (429, 503): + opener = QueueOpener(Response(b'[{"id":99,"title":"A new genre"}]'), Response(), + *(HTTPError('https://private.test/private-key', status, 'private-key', {}, None) + for _ in range(3))) + upstream = self.sync.Upstream('private-key', interval=0, opener=opener) + with patch.object(self.sync.time, 'sleep'), self.assertRaises(self.sync.SyncError): + self.sync.synchronize(self.source, self.stage, upstream) + self.assertEqual(len(opener.calls), 5) + self.assertFalse(self.stage.exists()) + self.assertEqual(self.source.read_bytes(), original) + + def test_page_limit_and_existing_candidates_are_never_published_or_overwritten(self): + original = self.source.read_bytes() + routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated'}]} + with self.assertRaises(self.sync.SyncError): + self.sync.synchronize(self.source, self.stage, FakeUpstream(routes), max_pages=1) + self.assertFalse(self.stage.exists()) + self.assertEqual(self.source.read_bytes(), original) + self.stage.write_bytes(b'existing candidate') + with self.assertRaises(FileExistsError): + self.sync.synchronize(self.source, self.stage, FakeUpstream()) + self.assertEqual(self.stage.read_bytes(), b'existing candidate') + with self.assertRaises(ValueError): + self.sync.synchronize(self.source, self.source, FakeUpstream()) + self.assertEqual(self.source.read_bytes(), original) + + def test_existing_id_does_not_end_pagination_and_missing_ids_use_indices(self): + routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha'}], + 'movie/by/filtres/0/created/1': [{'id': 70, 'type': 'movie', 'title': 'Next page'}], + 'movie/by/filtres/0/created/2': [], + 'movie/by/70': {'id': 70, 'type': 'movie', 'title': 'Next page', 'sources': []}, + 'season/by/serie/2': [{'title': 'Season 1 refreshed', 'episodes': [ + {'title': 'Episode 1 refreshed', 'sources': []}, {'title': 'Episode without ID', 'sources': []}]}]} + report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes)) + self.assertEqual(report['added']['movies'], 1) + self.assertEqual(report['added']['episodes'], 1) + self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM seasons'), [(1001, 'Season 1 refreshed')]) + self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM episodes ORDER BY episode_index'), + [(501, 'Episode 1 refreshed'), (None, 'Episode without ID')]) + self.assertEqual(self.rows(self.stage, 'SELECT source_id FROM episode_sources'), [(201,)]) + + def test_advertisements_channels_and_detail_ads_are_not_imported(self): + items = [ + {'id': 70, 'type': 'movie', 'title': 'Telegram @channelname'}, + {'id': 71, 'type': 'movie', 'title': 'Innocent', 'description': 'نسخه جدید ملودی باکس'}, + {'id': 72, 'type': 'channel', 'title': 'Live channel'}, + {'id': 73, 'type': 'movie', 'title': 'Looks like a movie'}] + upstream = FakeUpstream({'movie/by/filtres/0/created/0': items, + 'movie/by/filtres/0/created/1': [], + 'movie/by/73': {'id': 73, 'type': 'movie', 'title': 'Telegram @channelname'}}) + report = self.sync.synchronize(self.source, self.stage, upstream) + self.assertEqual(report['added']['titles'], 0) + self.assertNotIn('movie/by/70', upstream.calls) + self.assertNotIn('movie/by/71', upstream.calls) + self.assertNotIn('movie/by/72', upstream.calls) + self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles'), [(65,)]) + + def test_reordered_stable_ids_and_truncated_nested_lists_retain_every_old_entry(self): + first = {'season/by/serie/2': [ + {'id': 1001, 'episodes': [{'id': 502, 'title': 'Episode 2', 'sources': [ + {'id': 202, 'url': 'https://video.test/old2'}]}]}, + {'id': 1002, 'title': 'Season 2', 'episodes': [{'id': 601, 'title': 'S2 E1'}]}]} + self.sync.synchronize(self.source, self.stage, FakeUpstream(first)) + second = Path(self.temp.name) / 'second.sqlite' + routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated Alpha', + 'sources': [], 'genres': []}], + 'movie/by/filtres/0/created/1': [], + 'season/by/serie/2': [ + {'id': 1002, 'title': 'Season Two', 'episodes': []}, + {'id': 1001, 'episodes': [{'id': 502, 'title': 'Updated Episode 2', 'sources': [ + {'id': 202, 'url': 'https://video.test/refreshed'}]}, + {'id': 501, 'sources': []}]}]} + report = self.sync.synchronize(self.stage, second, FakeUpstream(routes)) + self.assertEqual(report['added'], dict.fromkeys(report['added'], 0)) + self.assertEqual(self.rows(second, 'SELECT season_index,id FROM seasons ORDER BY season_index'), + [(0, 1001), (1, 1002)]) + self.assertEqual(self.rows(second, 'SELECT season_index,episode_index,id FROM episodes ORDER BY 1,2'), + [(0, 0, 501), (0, 1, 502), (1, 0, 601)]) + self.assertEqual(self.rows(second, 'SELECT url FROM episode_sources WHERE source_id=202'), + [('https://video.test/refreshed',)]) + for table in ('seasons', 'episodes', 'movie_sources', 'episode_sources'): + self.assertGreaterEqual(self.rows(second, 'SELECT COUNT(*) FROM ' + table)[0][0], + self.rows(self.stage, 'SELECT COUNT(*) FROM ' + table)[0][0]) + third = Path(self.temp.name) / 'third.sqlite' + self.sync.synchronize(second, third, FakeUpstream()) + for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'): + self.assertEqual(self.rows(second, 'SELECT * FROM ' + table), self.rows(third, 'SELECT * FROM ' + table)) + season = json.loads(self.rows(second, 'SELECT raw_json FROM seasons WHERE id=1001')[0][0]) + self.assertEqual([e['id'] for e in season['episodes']], [501, 502]) + self.assertEqual(season['episodes'][1]['sources'][0]['url'], 'https://video.test/refreshed') + movie = json.loads(self.rows(second, 'SELECT raw_json FROM titles WHERE id=1')[0][0]) + self.assertTrue(movie['sources']) + self.assertTrue(movie['genres']) + + def test_old_series_absent_from_catalogue_and_new_series_receive_episodes_idempotently(self): + new_series = {'id': 70, 'type': 'serie', 'title': 'New Series'} + old_episode = {'id': 501, 'title': 'Episode 1', 'sources': []} + new_episode = {'id': 502, 'title': 'Episode 2', 'sources': [ + {'id': 202, 'quality': '1080', 'type': 'mkv', 'url': 'https://video.test/e2.mkv'}]} + routes = {'serie/by/filtres/0/created/0': [new_series], + 'serie/by/filtres/0/created/1': [], + 'season/by/serie/2': [{'id': 1001, 'title': 'Season 1', 'episodes': [old_episode, new_episode]}], + 'season/by/serie/70': [{'id': 1070, 'title': 'New Season', 'episodes': [ + {'id': 701, 'title': 'Pilot', 'sources': [ + {'id': 702, 'url': 'https://video.test/pilot.mp4', 'type': 'mp4'}]}]}]} + report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes)) + self.assertEqual(report['added'], {'titles': 1, 'movies': 0, 'series': 1, 'episodes': 2, 'sources': 2}) + self.assertEqual(report['updated_series_ids'], [2, 70]) + self.assertEqual(report['updated_series_count'], 2) + self.assertEqual(self.rows(self.stage, 'SELECT id FROM episodes ORDER BY id'), [(501,), (502,), (701,)]) + raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM seasons WHERE series_id=2')[0][0]) + self.assertEqual(len(raw['episodes']), 2) + self.assertEqual(raw['episodes'][0]['sources'][0]['id'], 201) + self.assertEqual(raw['episodes'][1], new_episode) + catalog = load_service().Catalog(self.stage) + try: + served = catalog.dispatch('/api/season/by/serie/2/public/') + self.assertEqual(served[0]['episodes'][1], new_episode) + finally: + catalog.close() + second_stage = Path(self.temp.name) / 'second.sqlite' + again = self.sync.synchronize(self.stage, second_stage, FakeUpstream(routes)) + self.assertEqual(again['added'], dict.fromkeys(report['added'], 0)) + self.assertEqual(again['updated_series_ids'], []) + for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'): + self.assertEqual(self.rows(self.stage, 'SELECT * FROM ' + table), + self.rows(second_stage, 'SELECT * FROM ' + table)) + self.assertEqual(self.metadata(second_stage)['original_snapshot_built_at_utc'], + self.metadata(self.source)['built_at_utc']) + for (url,) in self.rows(second_stage, 'SELECT source_url FROM series_fetch_status'): + self.assertTrue(url.startswith('https://ninwinios.info/api/season/by/serie/')) + self.assertNotIn('key', url) + + def test_new_movie_details_staged_source_untouched_and_real_counts(self): + original = self.source.read_bytes() + movie = {'id': 70, 'type': 'movie', 'title': 'New Film', 'year': 2026, + 'genres': [{'id': 5, 'title': 'Drama'}], 'country': [{'id': 3, 'title': 'Iran'}], + 'image': 'https://images.test/never-fetch.jpg'} + detail = dict(movie, description='New description', sources=[ + {'id': 700, 'type': 'mkv', 'url': 'https://video.test/never-fetch.mkv?key=opaque'}]) + upstream = FakeUpstream({'genre/all': [{'id': 5, 'title': 'Drama'}], + 'country/all': [{'id': 3, 'title': 'Iran'}], + 'movie/by/filtres/0/created/0': [movie, movie], + 'movie/by/filtres/0/created/1': [], 'movie/by/70': detail}) + progress = [] + report = self.sync.synchronize(self.source, self.stage, upstream, progress=progress.append) + self.assertEqual(self.source.read_bytes(), original) + self.assertEqual(report['added']['titles'], 1) + self.assertEqual(report['added']['movies'], 1) + self.assertEqual(report['added']['series'], 0) + self.assertEqual(report['added']['sources'], 1) + self.assertEqual(report['counts']['titles'], 66) + self.assertEqual(report['counts']['episodes'], 1) + self.assertEqual(report['requests_count'], upstream.requests_count) + self.assertFalse(report['media_files_downloaded']) + self.assertEqual(upstream.calls.count('movie/by/70'), 1) + self.assertIn('season/by/serie/2', upstream.calls) + self.assertTrue(all('http' not in path and '.mkv' not in path and '.jpg' not in path + for path in upstream.calls)) + raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM titles WHERE id=70')[0][0]) + self.assertEqual(raw, detail) + self.assertEqual(self.rows(self.stage, 'SELECT url FROM movie_sources WHERE title_id=70')[0][0], + detail['sources'][0]['url']) + catalog = load_service().Catalog(self.stage) + try: + served = catalog.dispatch('/api/movie/by/70/public/') + self.assertEqual(served, detail) + finally: + catalog.close() + self.assertEqual(self.rows(self.stage, 'PRAGMA integrity_check'), [('ok',)]) + self.assertEqual(self.rows(self.stage, 'PRAGMA foreign_key_check'), []) + metadata = self.metadata(self.stage) + self.assertEqual(metadata['original_snapshot_built_at_utc'], self.metadata(self.source)['built_at_utc']) + self.assertNotEqual(metadata['built_at_utc'], self.metadata(self.source)['built_at_utc']) + self.assertEqual(metadata['built_at_utc'], metadata['synchronized_at_utc']) + self.assertEqual(metadata['sync_interval_seconds'], 259200) + self.assertEqual(metadata['counts']['titles'], 66) + self.assertFalse(metadata['media_files_downloaded']) + self.assertEqual(metadata['last_sync']['upstream_base'], 'https://ninwinios.info/api/') + self.assertEqual(json.loads(json.dumps(report)), report) + self.assertTrue(progress) + for item in progress: + self.assertLessEqual(set(item), {'phase', 'count', 'total'}) + self.assertIsInstance(item['phase'], str) + self.assertIsInstance(item['count'], int) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/tests/test_catalog_sync_runner.py b/server/tests/test_catalog_sync_runner.py new file mode 100644 index 0000000..399c745 --- /dev/null +++ b/server/tests/test_catalog_sync_runner.py @@ -0,0 +1,271 @@ +"""Atomic publication and rollback for the catalogue updater.""" +import grp +import hashlib +from contextlib import closing, redirect_stdout +import importlib.util +import io +import json +import os +from pathlib import Path +import sqlite3 +import stat +import tempfile +import unittest + +import test_catalog_api as fixtures + +SERVER = Path(__file__).resolve().parents[1] + + +def runner_module(): + spec = importlib.util.spec_from_file_location('catalog_sync_runner', SERVER / 'catalog_sync_runner.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class SyncRunnerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.database = fixtures.fixture(self.root) + self.state = self.root / 'state' + self.stamp = '2026-11-03T19:00:00+00:00' + self.api = runner_module() + + def tearDown(self): + self.temp.cleanup() + + def candidate(self, source, stage, upstream, **kwargs): + with closing(sqlite3.connect(source)) as src, closing(sqlite3.connect(stage)) as dest: + src.backup(dest) + dest.execute('UPDATE metadata SET value_json=? WHERE key=?', + (json.dumps(self.stamp), 'built_at_utc')) + dest.commit() + return {'added_movies': 0, 'added_episodes': 0, 'built_at_utc': self.stamp} + + def digest(self): + return hashlib.sha256(self.database.read_bytes()).hexdigest() + + def test_progress_normalizes_core_count_without_logging_other_fields(self): + for event, completed in (({'count': 0}, 0), ({'count': 3}, 3), + ({'completed': 4, 'count': 5}, 4)): + with self.subTest(event=event): + output = io.StringIO() + with redirect_stdout(output): + self.api.emit_progress({**event, 'phase': 'movies', 'total': 7, + 'api_key': 'never-print-this'}) + self.assertEqual(json.loads(output.getvalue()), + {'progress': {'phase': 'movies', 'completed': completed, 'total': 7}}) + + def test_publish_is_atomic_and_records_success_after_health(self): + before = self.digest() + events = [] + def restart(): + events.append('restart') + with closing(sqlite3.connect(self.database)) as db: + stamp = json.loads(db.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0]) + self.assertEqual(stamp, self.stamp) + def health(stamp): + self.assertEqual(stamp, self.stamp) + self.assertFalse((self.state / 'last-success.json').exists()) + events.append('healthy') + result = self.api.run_update(self.database, self.state, object(), + synchronize_fn=self.candidate, restart_fn=restart, health_fn=health) + self.assertEqual(events, ['restart', 'healthy']) + self.assertNotEqual(self.digest(), before) + backup = self.database.with_name('catalog.previous.sqlite') + self.assertEqual(hashlib.sha256(backup.read_bytes()).hexdigest(), before) + self.assertEqual(result['status'], 'success') + self.assertEqual(json.loads((self.state / 'last-success.json').read_text())['status'], 'success') + + @unittest.skipUnless(os.geteuid() == 0, 'requires root to set group ownership') + def test_publish_preserves_group_readable_database_ownership_and_mode(self): + group = grp.getgrnam('www-data').gr_gid + os.chown(self.database, 0, group) + os.chmod(self.database, 0o640) + observed = [] + def restart(): + published = self.database.stat() + observed.append((published.st_uid, published.st_gid, stat.S_IMODE(published.st_mode))) + self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate, + restart_fn=restart, health_fn=lambda _: None) + self.assertEqual(observed, [(0, group, 0o640)]) + published = self.database.stat() + self.assertEqual((published.st_uid, published.st_gid), (0, group)) + self.assertEqual(stat.S_IMODE(published.st_mode), 0o640) + + def test_fetch_failure_never_changes_live_database_or_restarts(self): + before = self.digest() + def fail(*_args, **_kwargs): + raise RuntimeError('network unavailable') + with self.assertRaises(self.api.UpdateError): + self.api.run_update(self.database, self.state, object(), synchronize_fn=fail, + restart_fn=lambda: self.fail('must not restart'), health_fn=lambda _: None) + self.assertEqual(self.digest(), before) + self.assertFalse((self.state / 'last-success.json').exists()) + self.assertEqual(json.loads((self.state / 'last-result.json').read_text())['status'], 'failed') + self.assertEqual(list(self.root.glob('.catalog-sync-*.sqlite*')), []) + + def test_failed_health_rolls_back_before_restarting_old_catalogue(self): + before = self.digest() + with closing(sqlite3.connect(self.database)) as db: + old_stamp = json.loads(db.execute( + "SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0]) + restarts = [] + health_checks = [] + def restart(): + restarts.append(self.digest()) + def fail_health(stamp): + health_checks.append(stamp) + if stamp == self.stamp: + raise RuntimeError('new API not healthy') + self.assertEqual(self.digest(), before) + with self.assertRaises(self.api.UpdateError): + self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate, + restart_fn=restart, health_fn=fail_health) + self.assertEqual(len(restarts), 2) + self.assertNotEqual(restarts[0], before) + self.assertEqual(restarts[1], before) + self.assertEqual(health_checks, [self.stamp, old_stamp]) + self.assertEqual(self.digest(), before) + self.assertFalse((self.state / 'last-success.json').exists()) + self.assertFalse(json.loads((self.state / 'last-result.json').read_text())['rollback_failed']) + + def test_failed_rollback_health_requires_operator_attention(self): + before = self.digest() + with closing(sqlite3.connect(self.database)) as db: + old_stamp = json.loads(db.execute( + "SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0]) + health_checks = [] + restarts = [] + def fail_health(stamp): + health_checks.append(stamp) + raise RuntimeError('API not healthy') + with self.assertRaisesRegex(self.api.UpdateError, 'operator attention'): + self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate, + restart_fn=lambda: restarts.append(self.digest()), health_fn=fail_health) + self.assertEqual(health_checks, [self.stamp, old_stamp]) + self.assertEqual(len(restarts), 2) + self.assertEqual(restarts[1], before) + self.assertEqual(self.digest(), before) + self.assertFalse((self.state / 'last-success.json').exists()) + self.assertTrue(json.loads((self.state / 'last-result.json').read_text())['rollback_failed']) + + def test_concurrent_run_is_rejected_before_fetching(self): + import fcntl + self.state.mkdir() + with (self.state / 'sync.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + with self.assertRaises(self.api.UpdateError): + self.api.run_update(self.database, self.state, object(), + synchronize_fn=lambda *_a, **_k: self.fail('overlapping fetch'), + restart_fn=lambda: None, health_fn=lambda _: None) + + def assert_candidate_rejected(self, mutate): + before = self.digest() + restarts = [] + def candidate(source, stage, upstream, **kwargs): + report = self.candidate(source, stage, upstream, **kwargs) + with closing(sqlite3.connect(stage)) as db: + mutate(db) + db.commit() + return report + with self.assertRaises(self.api.UpdateError): + self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate, + restart_fn=lambda: restarts.append(True), health_fn=lambda _: None) + self.assertEqual(restarts, []) + self.assertEqual(self.digest(), before) + self.assertFalse((self.state / 'last-success.json').exists()) + + def test_required_reference_and_source_tables_cannot_be_removed_or_emptied(self): + for table in ('genres', 'countries', 'title_genres', 'title_countries', + 'series_fetch_status', 'movie_sources', 'episode_sources'): + for statement in ('DELETE FROM ', 'DROP TABLE '): + with self.subTest(table=table, statement=statement): + self.assert_candidate_rejected(lambda db: db.execute(statement + table)) + + def test_new_movie_sources_cannot_mask_existing_title_source_loss(self): + def replace(db): + db.execute("INSERT INTO titles(id,type,raw_json) VALUES (66,'movie','{}')") + db.execute('UPDATE movie_sources SET title_id=66 WHERE title_id=1') + self.assert_candidate_rejected(replace) + + def test_other_title_sources_cannot_mask_existing_title_source_loss(self): + def replace(db): + db.execute('UPDATE movie_sources SET title_id=3,source_index=1 WHERE title_id=1') + self.assert_candidate_rejected(replace) + + def test_new_episode_sources_cannot_mask_existing_episode_source_loss(self): + def replace(db): + db.execute("INSERT INTO episodes(series_id,season_index,episode_index,id,raw_json) " + "VALUES (2,0,1,502,'{}')") + db.execute('UPDATE episode_sources SET episode_index=1') + self.assert_candidate_rejected(replace) + + def test_null_id_episode_sources_are_preserved_by_index(self): + with closing(sqlite3.connect(self.database)) as db: + db.execute('UPDATE episodes SET id=NULL') + db.commit() + def replace(db): + db.execute("INSERT INTO episodes(series_id,season_index,episode_index,raw_json) " + "VALUES (2,0,1,'{}')") + db.execute('UPDATE episode_sources SET episode_index=1') + self.assert_candidate_rejected(replace) + + def test_source_url_replacement_with_stable_ids_can_be_published(self): + replacement = 'https://media.test/replacement.mkv?token=new' + def candidate(source, stage, upstream, **kwargs): + report = self.candidate(source, stage, upstream, **kwargs) + with closing(sqlite3.connect(stage)) as db: + for table in ('movie_sources', 'episode_sources'): + raw = json.loads(db.execute( + 'SELECT raw_json FROM ' + table + ' WHERE source_id=?', + (101 if table == 'movie_sources' else 201,)).fetchone()[0]) + raw['url'] = replacement + db.execute('UPDATE ' + table + ' SET url=?,raw_json=? WHERE source_id=?', + (replacement, json.dumps(raw), raw['id'])) + for table, title_id in (('titles', 1), ('episodes', 501)): + raw = json.loads(db.execute('SELECT raw_json FROM ' + table + ' WHERE id=?', + (title_id,)).fetchone()[0]) + raw['sources'][0]['url'] = replacement + db.execute('UPDATE ' + table + ' SET raw_json=? WHERE id=?', + (json.dumps(raw), title_id)) + season = json.loads(db.execute('SELECT raw_json FROM seasons').fetchone()[0]) + season['episodes'][0]['sources'][0]['url'] = replacement + db.execute('UPDATE seasons SET raw_json=?', (json.dumps(season),)) + db.commit() + return report + result = self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate, + restart_fn=lambda: None, health_fn=lambda _: None) + self.assertEqual(result['status'], 'success') + with closing(sqlite3.connect(self.database)) as db: + self.assertEqual(db.execute('SELECT source_id,url FROM movie_sources WHERE title_id=1').fetchone(), + (101, replacement)) + self.assertEqual(db.execute('SELECT source_id,url FROM episode_sources').fetchone(), + (201, replacement)) + + def test_episode_sources_follow_stable_id_when_episode_index_changes(self): + def candidate(source, stage, upstream, **kwargs): + report = self.candidate(source, stage, upstream, **kwargs) + with closing(sqlite3.connect(stage)) as db: + db.execute('UPDATE episodes SET episode_index=1 WHERE id=501') + db.execute('UPDATE episode_sources SET episode_index=1') + db.commit() + return report + result = self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate, + restart_fn=lambda: None, health_fn=lambda _: None) + self.assertEqual(result['status'], 'success') + + def test_incomplete_candidate_cannot_replace_the_catalogue(self): + before = self.digest() + def truncate(source, stage, upstream, **kwargs): + result = self.candidate(source, stage, upstream, **kwargs) + with closing(sqlite3.connect(stage)) as db: + db.execute('DELETE FROM titles WHERE id=65') + db.commit() + return result + with self.assertRaises(self.api.UpdateError): + self.api.run_update(self.database, self.state, object(), synchronize_fn=truncate, + restart_fn=lambda: self.fail('invalid candidate'), health_fn=lambda _: None) + self.assertEqual(self.digest(), before) diff --git a/server/tests/test_catalog_sync_units.py b/server/tests/test_catalog_sync_units.py new file mode 100644 index 0000000..2f2bb3a --- /dev/null +++ b/server/tests/test_catalog_sync_units.py @@ -0,0 +1,38 @@ +"""Operational boundaries of the installed updater schedule.""" +import configparser +from pathlib import Path +import unittest + +SERVER = Path(__file__).resolve().parents[1] + + +class SyncUnitTests(unittest.TestCase): + def unit(self, name): + config = configparser.ConfigParser(interpolation=None, strict=False) + config.optionxform = str + config.read(SERVER / name) + return config + + def test_timer_uses_three_day_interval_and_boot_catch_up(self): + timer = self.unit('reelbox-sync.timer') + self.assertEqual(timer['Timer']['OnUnitActiveSec'], '3d') + self.assertEqual(timer['Timer']['OnBootSec'], '15min') + self.assertEqual(timer['Timer']['Unit'], 'reelbox-sync.service') + self.assertNotIn('OnCalendar', timer['Timer']) + self.assertEqual(timer['Install']['WantedBy'], 'timers.target') + + def test_updater_is_bounded_and_separate_from_public_api(self): + service = self.unit('reelbox-sync.service')['Service'] + self.assertEqual(service['Type'], 'oneshot') + self.assertEqual(service['TimeoutStartSec'], '8h') + self.assertEqual(service['RestartSec'], '1h') + self.assertEqual(service['ProtectSystem'], 'strict') + self.assertEqual(service['ProtectHome'], 'true') + self.assertEqual(service['EnvironmentFile'], '/etc/reelbox/sync.env') + self.assertEqual(service['UMask'], '0077') + self.assertIn('catalog_sync_runner.py', service['ExecStart']) + self.assertEqual(service['ReadWritePaths'], '/var/lib/reelbox-api /var/lib/reelbox-sync') + api = self.unit('reelbox-api.service')['Service'] + self.assertEqual(api['IPAddressDeny'], 'any') + self.assertEqual(api['IPAddressAllow'], 'localhost') + self.assertNotIn('sync.env', api.get('EnvironmentFile', '')) diff --git a/test/api_config_recovery_test.dart b/test/api_config_recovery_test.dart new file mode 100644 index 0000000..238dff6 --- /dev/null +++ b/test/api_config_recovery_test.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' as fixtures; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('Iran migration is restored by a fresh instance without contacting discovery', () async { + final adapter = fixtures.StubAdapter( + (request) => request.uri.host == 'ninnies-server.info' + ? ResponseBody.fromString(ApiService.defaultBaseUrl, 200) + : fixtures.jsonBody([ + {'id': 1, 'title': 'Drama'}, + ]), + ); + await ApiService(dio: Dio()..httpClientAdapter = adapter).initialize(); + var reads = 0; + final offline = fixtures.StubAdapter( + (_) => throw StateError('No network permitted'), + ); + final restored = ApiService( + dio: Dio()..httpClientAdapter = offline, + preferences: () async { + reads++; + return SharedPreferences.getInstance(); + }, + ); + await restored.initialize(discover: false); + expect(reads, 1); + expect(restored.baseUrl, ApiService.defaultBaseUrl); + expect(offline.requests, isEmpty); + }); + + for (final value in [ + 'broken', + 'https://untrusted.example/api/', + 'http://ninwinios.info/api/', + 'https://fixture:fixture@ninwinios.info/api/', + 'https://ninwinios.info/api/?x=1', + 'https://ninwinios.info/api/#fragment', + 'https://127.0.0.1/api/', + 'https://ninwinios.info:444/api/', + ]) { + test( + 'invalid cached configuration cannot change the trusted destination: ${Uri.tryParse(value)?.host ?? 'malformed'}', + () async { + SharedPreferences.setMockInitialValues({ + 'reelbox.trusted-api.v1': value, + }); + final adapter = fixtures.StubAdapter( + (_) => throw StateError('No network permitted'), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.initialize(discover: false); + expect(api.baseUrl, ApiService.defaultBaseUrl); + expect(adapter.requests, isEmpty); + }, + ); + } + + test( + 'removed discovery cannot contact an offline or invalid-schema server', + () async { + SharedPreferences.setMockInitialValues({ + 'reelbox.trusted-api.v1': ApiService.defaultBaseUrl, + }); + final offline = fixtures.StubAdapter( + (r) => throw DioException( + requestOptions: r, + type: DioExceptionType.connectionError, + ), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = offline); + await api.initialize(); + expect(api.baseUrl, ApiService.defaultBaseUrl); + expect(offline.requests, isEmpty); + expect( + (await SharedPreferences.getInstance()).getString( + 'reelbox.trusted-api.v1', + ), + isNull, + ); + SharedPreferences.setMockInitialValues({}); + final invalid = fixtures.StubAdapter( + (r) => r.uri.host == 'ninnies-server.info' + ? ResponseBody.fromString(ApiService.defaultBaseUrl, 200) + : fixtures.jsonBody([]), + ); + await ApiService(dio: Dio()..httpClientAdapter = invalid).initialize(); + expect(invalid.requests, isEmpty); + expect( + (await SharedPreferences.getInstance()).getString( + 'reelbox.trusted-api.v1', + ), + isNull, + ); + }, + ); + + test( + 'read-only transport failure recovers once without a discovery storm', + () async { + var attempts = 0; + final adapter = fixtures.StubAdapter((r) { + if (++attempts == 1) { + throw DioException( + requestOptions: r, + type: DioExceptionType.connectionError, + ); + } + return fixtures.jsonBody([ + {'id': 1, 'title': 'Drama'}, + ]); + }); + final result = await ApiService(dio: Dio()..httpClientAdapter = adapter) + .genres(); + expect(result.single.title, 'Drama'); + expect(attempts, 2); + expect( + adapter.requests.every( + (r) => r.uri.origin == 'https://reelbox.dartty.top', + ), + true, + ); + }, + ); + + test('persistent transport failure is bounded to two GET attempts', () async { + final adapter = fixtures.StubAdapter( + (r) => throw DioException( + requestOptions: r, + type: DioExceptionType.receiveTimeout, + ), + ); + await expectLater( + ApiService(dio: Dio()..httpClientAdapter = adapter).genres(), + throwsA(isA()), + ); + expect(adapter.requests, hasLength(2)); + }); + + for (final status in [401, 403, 404, 429, 500]) { + test('HTTP $status is preserved without automatic retry', () async { + final adapter = fixtures.StubAdapter( + (_) => fixtures.jsonBody({}, status: status), + ); + await expectLater( + ApiService(dio: Dio()..httpClientAdapter = adapter).genres(), + throwsA( + isA().having((e) => e.statusCode, 'status', status), + ), + ); + expect(adapter.requests, hasLength(1)); + }); + } + + for (final type in [ + DioExceptionType.cancel, + DioExceptionType.badCertificate, + ]) { + test('$type is never retried', () async { + final adapter = fixtures.StubAdapter( + (r) => throw DioException(requestOptions: r, type: type), + ); + await expectLater( + ApiService(dio: Dio()..httpClientAdapter = adapter).genres(), + throwsA(isA()), + ); + expect(adapter.requests, hasLength(1)); + }); + } + + test('POST transport failure is not replayed', () async { + final adapter = fixtures.StubAdapter( + (r) => throw DioException( + requestOptions: r, + type: DioExceptionType.connectionError, + ), + ); + await expectLater( + ApiService(dio: Dio()..httpClientAdapter = adapter) + .post('genre/read', {'fixture': true}), + throwsA(isA()), + ); + expect(adapter.requests, hasLength(1)); + }); + + test( + 'concurrent failed GETs share cache loading and each retry only once', + () async { + var reads = 0; + final attempts = {}; + final adapter = fixtures.StubAdapter((r) { + final n = attempts.update(r.uri.path, (n) => n + 1, ifAbsent: () => 1); + if (n == 1) { + throw DioException( + requestOptions: r, + type: DioExceptionType.connectionError, + ); + } + return fixtures.jsonBody([]); + }); + final cache = Completer(); + final api = ApiService( + dio: Dio()..httpClientAdapter = adapter, + preferences: () { + reads++; + return cache.future; + }, + ); + final requests = Future.wait([ + for (final n in [1, 2, 3]) api.get('fixture-$n'), + ]); + await Future.delayed(Duration.zero); + cache.complete(await SharedPreferences.getInstance()); + await requests; + expect(reads, 1); + expect(attempts.values, everyElement(2)); + expect(adapter.requests, hasLength(6)); + }, + ); + + test( + 'production default persists Iran without fetching remote configuration', + () async { + SharedPreferences.setMockInitialValues({}); + final adapter = fixtures.StubAdapter((request) { + if (request.uri.host == 'ninnies-server.info') { + return ResponseBody.fromString(ApiService.defaultBaseUrl, 200); + } + return fixtures.jsonBody([ + {'id': 1, 'title': 'Drama'}, + ]); + }); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.initialize(); + final preferences = await SharedPreferences.getInstance(); + expect( + preferences.getString('reelbox.api-selection.v1'), + 'primary', + ); + expect(preferences.getString('reelbox.trusted-api.v1'), isNull); + expect(adapter.requests, isEmpty); + }, + ); +} diff --git a/test/api_content_policy_test.dart b/test/api_content_policy_test.dart new file mode 100644 index 0000000..d097c28 --- /dev/null +++ b/test/api_content_policy_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; + +import 'api_service_test.dart' as fixtures; + +void main() { + test( + 'all title decoders reject channels and promotional catalog entries', + () { + final api = ApiService(); + expect( + api + .decodeTitles([ + {'id': 1, 'title': 'A real movie', 'type': 'movie'}, + {'id': 2, 'title': 'نورتون', 'type': 'movie'}, + {'id': 3, 'title': 'Station', 'type': 'channel'}, + {'id': 4, 'title': 'ملودی باکس'}, + ]) + .map((e) => e.id), + [1], + ); + }, + ); + test( + 'home exposes only film discovery, never updater/support/public feeds', + () async { + final adapter = fixtures.StubAdapter( + (_) => fixtures.jsonBody({ + 'posters': [ + {'id': 1, 'title': 'Film'}, + {'id': 2, 'title': 'ملودی باکس'}, + ], + 'genres': [], + 'slides': [], + 'channels': [ + {'id': 3, 'title': 'Station'}, + ], + 'public_playlists': [], + 'news': {'title': 'Original news'}, + 'support': 'https://t.me/support', + 'latest_version': '100', + 'settings': {'installer': 'https://example.com/latest.apk'}, + 'unknown_promotion': 'https://t.me/original', + }), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + final home = await api.home(); + expect(home.keys.toSet(), {'posters', 'genres', 'slides'}); + expect((home['posters'] as List).length, 1); + }, + ); + test( + 'removed API wrappers cannot issue public-playlist/live-channel requests', + () async { + final adapter = fixtures.StubAdapter( + (_) => fixtures.jsonBody([ + {'id': 1, 'title': 'Old entry'}, + ]), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + expect(await api.channels(), isEmpty); + expect(await api.categories(), isEmpty); + expect(await api.playlists(), isEmpty); + expect(await api.playlistItems(1), isEmpty); + await expectLater(api.channel(1), throwsA(isA())); + expect(adapter.requests, isEmpty); + }, + ); + test( + 'generic paths and query maps cannot bypass removed endpoint policy', + () async { + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + for (final path in [ + 'channel/by/1/${ApiService.sharedKey}/', + 'news/all/', + 'updates/latest/', + ]) { + await expectLater(api.get(path), throwsA(isA())); + } + await expectLater( + api.get( + '${api.hostRoot}/api-user/newapi/like.php', + query: {'action': 'showlist'}, + ), + throwsA(isA()), + ); + expect(adapter.requests, isEmpty); + }, + ); + test( + 'a raw page containing only hidden items still has a continuation', + () async { + final adapter = fixtures.StubAdapter( + (_) => fixtures.jsonBody([ + {'id': 2, 'title': 'ملودی باکس', 'type': 'movie'}, + ]), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + final page = await api.catalog(page: 5); + expect(page, isEmpty); + expect((page as CatalogPage).nextPage, 6); + final search = await api.search('film', page: 3); + expect(search, isEmpty); + expect((search as CatalogPage).nextPage, 4); + }, + ); + test( + 'stored model round trips cannot retain hidden promotional metadata', + () { + final title = TitleRecord.fromJson({ + 'id': 1, + 'title': 'Film', + 'support': 'https://t.me/original', + 'news': ['Original app news'], + 'sources': [ + {'id': 1, 'url': 'https://example.com/film.mp4'}, + {'id': 2, 'url': 'https://example.com/new.apk'}, + ], + }); + final encoded = jsonEncode(title.toJson()); + expect(encoded.contains('t.me'), isFalse); + expect(encoded.contains('new.apk'), isFalse); + expect(title.sources, hasLength(1)); + expect(TitleRecord.fromJson({'id': 8, 'title': 'ملودی باکس'}).id, 0); + }, + ); +} diff --git a/test/api_iran_backend_test.dart b/test/api_iran_backend_test.dart new file mode 100644 index 0000000..919ff5a --- /dev/null +++ b/test/api_iran_backend_test.dart @@ -0,0 +1,86 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' as fixtures; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final key in ['reelbox.api-selection.v1', 'reelbox.trusted-api.v1']) { + for (final initializeFirst in [true, false]) { + test( + 'migrates $key before ${initializeFirst ? 'initialization' : 'first request'} without touching local data', + () async { + const library = + '{"saved":[{"id":7}],"locale":"fa","unknown":"keep exactly"}'; + SharedPreferences.setMockInitialValues({ + key: 'https://ninwinios.info/api/', + 'reelbox.local.v1': library, + 'unrelated-setting': 'keep', + }); + String? valueAtRequest; + final adapter = fixtures.StubAdapter((request) async { + final prefs = await SharedPreferences.getInstance(); + valueAtRequest = prefs.getString(key); + return fixtures.jsonBody([]); + }); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + if (initializeFirst) { + await api.initialize(); + expect(adapter.requests, isEmpty); + } + await api.genres(); + expect( + valueAtRequest, + key == 'reelbox.api-selection.v1' ? 'primary' : isNull, + ); + expect( + adapter.requests.single.uri.origin, + 'https://reelbox.dartty.top', + ); + expect(api.baseUrl, ApiService.iranBaseUrl); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('reelbox.api-selection.v1'), 'primary'); + expect(prefs.getString('reelbox.local.v1'), library); + expect(prefs.getString('unrelated-setting'), 'keep'); + }, + ); + } + } + + test( + 'Original API cannot be selected or injected as a production backend', + () async { + const original = 'https://ninwinios.info/api/'; + expect(() => ApiService(baseUrl: original), throwsA(isA())); + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await expectLater(api.setBaseUrl(original), throwsA(isA())); + expect(api.baseUrl, ApiService.iranBaseUrl); + for (final url in [ + '${original}genre/all/public/', + 'https://ninnies-server.info/api-user/newapi/config-server186.txt', + ]) { + await expectLater(api.get(url), throwsA(isA())); + } + expect(adapter.requests, isEmpty); + }, + ); + + test( + 'production starts on Iran without configuration discovery traffic', + () async { + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.initialize(); + expect(api.baseUrl, ApiService.iranBaseUrl); + expect(ApiService.defaultBaseUrl, ApiService.iranBaseUrl); + expect(adapter.requests, isEmpty); + await api.genres(); + expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top'); + }, + ); +} diff --git a/test/api_release_endpoint_test.dart b/test/api_release_endpoint_test.dart new file mode 100644 index 0000000..ef8a257 --- /dev/null +++ b/test/api_release_endpoint_test.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' as fixtures; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('fresh catalogue requests use HTTPS port 443', () async { + SharedPreferences.setMockInitialValues({}); + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.genres(); + expect(adapter.requests.single.uri.port, 443); + expect(api.baseUrl, 'https://reelbox.dartty.top/api/'); + }); + + test( + 'legacy 8443 selections migrate without retaining endpoint URLs', + () async { + SharedPreferences.setMockInitialValues({ + 'reelbox.api-selection.v1': 'https://reelbox.dartty.top:8443/api/', + 'reelbox.trusted-api.v1': 'https://reelbox.dartty.top:8443/api/', + }); + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.genres(); + expect(adapter.requests.single.uri.port, 443); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('reelbox.api-selection.v1'), 'primary'); + expect(prefs.containsKey('reelbox.trusted-api.v1'), isFalse); + }, + ); +} diff --git a/test/api_request_efficiency_test.dart b/test/api_request_efficiency_test.dart new file mode 100644 index 0000000..3662c2d --- /dev/null +++ b/test/api_request_efficiency_test.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; + +import 'api_service_test.dart' show StubAdapter, jsonBody; + +void main() { + test('reopening genre and country menus reuses only those successful lists', () async { + final adapter = StubAdapter((_) => jsonBody([{'id': 1, 'title': 'Tag'}])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter, + baseUrl: 'http://localhost/api/'); + final first = await api.genres(); + first.clear(); + for (var i = 0; i < 8; i++) { + expect((await api.genres()).single.id, 1); + expect((await api.countries()).single.id, 1); + } + expect(adapter.requests, hasLength(2)); + }); + test('concurrent identical metadata reads perform one transport operation', () async { + final gate = Completer(); + final entered = Completer(); + final adapter = StubAdapter((request) async { + if (!entered.isCompleted) entered.complete(); + await gate.future; + return jsonBody({'id': 1, 'title': 'Film', 'nested': {'items': [1]}}); + }); + final api = ApiService( + dio: Dio()..httpClientAdapter = adapter, + baseUrl: 'http://localhost/api/', + ); + final reads = List.generate(12, (_) => api.get('movie/by/1/public/')); + await entered.future; + // Drain dispatch microtasks while all response bodies remain gated. + await Future.delayed(Duration.zero); + gate.complete(); + final results = await Future.wait(reads); + expect(adapter.requests, hasLength(1)); + results.first['nested']['items'].add(2); + expect(results.last['nested']['items'], [1]); + await api.get('movie/by/1/public/'); + expect(adapter.requests, hasLength(2), reason: 'completed detail reads stay fresh'); + }); +} diff --git a/test/api_selector_test.dart b/test/api_selector_test.dart new file mode 100644 index 0000000..5e0a0df --- /dev/null +++ b/test/api_selector_test.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' as fixtures; + +const iranBase = 'https://reelbox.dartty.top/api/'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test( + 'malicious persisted selections cannot redirect the first network request', + () async { + for (final invalid in [ + 'https://reelbox.dartty.top:8443/api/', + 'http://reelbox.dartty.top:8443/api/', + 'https://reelbox.dartty.top.evil.example:8443/api/', + 'http://127.0.0.1:8123/api/', + 'https://user@reelbox.dartty.top:8443/api/', + ]) { + SharedPreferences.setMockInitialValues({ + 'reelbox.api-selection.v1': invalid, + }); + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.genres(); + expect(api.baseUrl, ApiService.defaultBaseUrl, reason: invalid); + expect( + adapter.requests.single.uri.origin, + 'https://reelbox.dartty.top', + ); + } + }, + ); + + test('explicit choice wins over delayed cache restoration', () async { + final cacheStarted = Completer(); + final cache = Completer(); + var reads = 0; + final api = ApiService( + preferences: () { + if (reads++ == 0) { + cacheStarted.complete(); + return cache.future; + } + return SharedPreferences.getInstance(); + }, + ); + final restoration = api.initialize(discover: false); + await cacheStarted.future; + await api.setBaseUrl(iranBase); + cache.complete(await SharedPreferences.getInstance()); + await restoration; + expect(api.baseUrl, iranBase); + }); + + test('actor search restores the selected origin before building its absolute route', () async { + await ApiService().setBaseUrl(iranBase); + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final restarted = ApiService(dio: Dio()..httpClientAdapter = adapter); + await restarted.actors(search: 'Example'); + expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top'); + }); + + test('Iran catalogue preserves media URLs and never falls back on failure', () async { + const media = + 'https://original-media.example:9443/video.mp4?token=Exact%2FValue&x=1'; + final adapter = fixtures.StubAdapter( + (_) => fixtures.jsonBody({ + 'id': 7, + 'title': 'Movie', + 'sources': [ + {'id': 3, 'url': media, 'type': 'mp4'}, + ], + }), + ); + final api = ApiService( + baseUrl: iranBase, + dio: Dio()..httpClientAdapter = adapter, + ); + expect((await api.poster(7)).sources.single.url, media); + final offline = fixtures.StubAdapter( + (r) => throw DioException( + requestOptions: r, + type: DioExceptionType.connectionError, + ), + ); + final failing = ApiService( + baseUrl: iranBase, + dio: Dio()..httpClientAdapter = offline, + ); + await expectLater(failing.genres(), throwsA(isA())); + expect(failing.baseUrl, iranBase); + expect(offline.requests, hasLength(2)); + expect( + offline.requests.every( + (r) => r.uri.origin == 'https://reelbox.dartty.top', + ), + isTrue, + ); + }); + + test( + 'concurrent initialization shares migration without discovery or probes', + () async { + SharedPreferences.setMockInitialValues({ + 'reelbox.api-selection.v1': 'https://ninwinios.info/api/', + }); + final cache = Completer(); + var reads = 0; + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService( + dio: Dio()..httpClientAdapter = adapter, + preferences: () { + reads++; + return cache.future; + }, + ); + final initialization = Future.wait([api.initialize(), api.initialize()]); + cache.complete(await SharedPreferences.getInstance()); + await initialization; + expect(reads, 1); + expect(adapter.requests, isEmpty); + expect(api.baseUrl, iranBase); + final restarted = ApiService(); + await restarted.initialize(discover: false); + expect(restarted.baseUrl, iranBase); + }, + ); + + test( + 'unsigned advertisements are never fetched or used during initialization', + () async { + for (final advertised in [iranBase, 'http://ninwinios.info/api/']) { + final adapter = fixtures.StubAdapter( + (r) => r.uri.host == 'ninnies-server.info' + ? ResponseBody.fromString(advertised, 200) + : fixtures.jsonBody([ + {'id': 1, 'title': 'Drama'}, + ]), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.initialize(); + expect(api.baseUrl, ApiService.defaultBaseUrl); + expect(adapter.requests, isEmpty); + } + }, + ); + + test('only exact HTTPS service origins and constructor loopback fixtures are accepted', () async { + for (final invalid in [ + 'http://reelbox.dartty.top:8443/api/', + 'https://reelbox.dartty.top:8443/api/', + 'https://reelbox.dartty.top:8444/api/', + 'https://reelbox.dartty.top.evil.example:8443/api/', + 'https://evil.reelbox.dartty.top:8443/api/', + 'https://ninwinios.info:8443/api/', + 'https://ninwinios.info/api/', + 'https://user@reelbox.dartty.top:8443/api/', + 'https://reelbox.dartty.top/api/?q=1', + 'https://reelbox.dartty.top/api/#fragment', + 'https://reelbox.dartty.top/api/../api/', + ' $iranBase', + ]) { + expect( + () => ApiService(baseUrl: invalid), + throwsA(isA()), + reason: invalid, + ); + await expectLater( + ApiService().setBaseUrl(invalid), + throwsA(isA()), + reason: invalid, + ); + } + for (final base in [ + 'http://127.0.0.1:4589/api/', + 'http://localhost:4589/api/', + 'http://[::1]:4589/api/', + ]) { + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService( + baseUrl: base, + dio: Dio()..httpClientAdapter = adapter, + ); + await api.genres(); + expect(adapter.requests.single.uri.origin, Uri.parse(base).origin); + } + }); + + test( + 'explicit Iran choice persists and restores before first catalogue request', + () async { + final first = ApiService(); + await first.setBaseUrl(iranBase); + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final restarted = ApiService(dio: Dio()..httpClientAdapter = adapter); + await restarted.genres(); + expect(restarted.baseUrl, iranBase); + expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top'); + await restarted.initialize(); + expect( + adapter.requests, + hasLength(1), + reason: 'Iran must not run original discovery', + ); + await restarted.setBaseUrl(ApiService.defaultBaseUrl); + final fresh = ApiService(); + await fresh.initialize(discover: false); + expect(fresh.baseUrl, iranBase); + }, + ); +} diff --git a/test/api_selector_ui_test.dart b/test/api_selector_ui_test.dart new file mode 100644 index 0000000..3891aac --- /dev/null +++ b/test/api_selector_ui_test.dart @@ -0,0 +1,201 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:reelbox/ui/library.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' as fixtures; +import 'api_selector_test.dart' show iranBase; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + fixtures.StubAdapter adapter() => fixtures.StubAdapter( + (r) => r.uri.path.contains('/first/') + ? fixtures.jsonBody({'posters': []}) + : fixtures.jsonBody([]), + ); + + for (final locale in ['en', 'fa']) { + testWidgets( + 'settings hides connection details and preserves migrated library in $locale', + (tester) async { + final network = adapter(); + final state = AppState( + api: ApiService(dio: Dio()..httpClientAdapter = network), + ); + addTearDown(state.dispose); + await state.initialize(discover: false); + await state.updateSettings(state.prefs.copyWith(locale: locale)); + const title = TitleRecord(id: 9, title: 'Saved title'); + await state.toggleSaved(title); + await state.recordWatch( + title, + sourceUrl: 'https://media.example/film.mp4?token=exact', + positionSeconds: 12, + ); + await state.createCollection('Keep collection'); + final storedLibrary = (await SharedPreferences.getInstance()).getString( + AppState.storageKey, + ); + var notifications = 0; + state.addListener(() => notifications++); + await tester.pumpWidget( + AppScope( + state: state, + child: const MaterialApp(home: SettingsPage()), + ), + ); + await tester.drag(find.byType(ListView), const Offset(0, -10000)); + await tester.pumpAndSettle(); + expect(state.api.baseUrl, iranBase); + expect(find.text(iranBase), findsNothing); + expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing); + expect( + find.text(locale == 'en' ? 'Iran Catalogue' : 'فهرست ایران'), + findsNothing, + ); + expect(find.byKey(const Key('catalog-api-selector')), findsNothing); + expect(find.text('Original API'), findsNothing); + expect(find.text('API اصلی'), findsNothing); + expect(find.text('Refresh API server'), findsNothing); + expect(notifications, 0); + expect( + network.requests, + isEmpty, + reason: 'Endpoint information must not probe or offer fallback', + ); + expect( + (await SharedPreferences.getInstance()).getString( + AppState.storageKey, + ), + storedLibrary, + ); + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + 'reelbox.api-selection.v1', + 'https://ninwinios.info/api/', + ); + await preferences.setString( + 'reelbox.trusted-api.v1', + 'https://ninwinios.info/api/', + ); + final restarted = AppState( + api: ApiService(dio: Dio()..httpClientAdapter = adapter()), + ); + addTearDown(restarted.dispose); + await restarted.initialize(discover: false); + expect(preferences.getString(AppState.storageKey), storedLibrary); + expect(restarted.api.baseUrl, iranBase); + expect(restarted.saved.single.title, 'Saved title'); + expect( + restarted.history.single.sourceUrl, + 'https://media.example/film.mp4?token=exact', + ); + expect(restarted.collections.single.name, 'Keep collection'); + expect(restarted.locale, locale); + }, + ); + } + + testWidgets( + 'Iran-only catalogue normalizes a legacy views browse on first mount', + (tester) async { + final network = adapter(); + final state = AppState( + api: ApiService(dio: Dio()..httpClientAdapter = network), + ); + addTearDown(state.dispose); + await state.initialize(discover: false); + await tester.pumpWidget( + AppScope( + state: state, + child: const MaterialApp(home: BrowsePage(initialOrder: 'views')), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Most viewed'), findsNothing); + expect(find.text('Recently added'), findsOneWidget); + final iranRequests = network.requests.where( + (r) => r.uri.host == 'reelbox.dartty.top', + ); + expect(iranRequests.any((r) => r.uri.path.contains('/created/')), isTrue); + expect(iranRequests.any((r) => r.uri.path.contains('/views/')), isFalse); + await tester.tap(find.byTooltip('Sort titles')); + await tester.pumpAndSettle(); + expect(find.text('Most viewed'), findsNothing); + await tester.tap(find.text('Release year')); + await tester.pumpAndSettle(); + await tester.pumpWidget( + AppScope( + state: state, + child: const MaterialApp(home: DiscoveryDirectoryPage()), + ), + ); + await tester.pumpAndSettle(); + final list = tester.widget(find.byType(ListView)); + final children = + (list.childrenDelegate as SliverChildListDelegate).children; + final titles = children + .whereType() + .map((tile) => (tile.title as Text).data) + .toList(); + expect(titles, isNot(contains('Most-viewed movies'))); + expect(titles, isNot(contains('Most-viewed series'))); + }, + ); + + testWidgets( + 'catalogue tabs survive simplified settings without changing local library', + (tester) async { + final network = adapter(); + final state = AppState( + api: ApiService(dio: Dio()..httpClientAdapter = network), + ); + addTearDown(state.dispose); + await state.initialize(discover: false); + await state.updateSettings(state.prefs.copyWith(introCompleted: true)); + await state.toggleSaved(const TitleRecord(id: 9, title: 'Saved title')); + await tester.pumpWidget(MyApp(state: state)); + await tester.pumpAndSettle(); + for (final tab in ['Browse', 'Search', 'Library', 'More']) { + await tester.tap(find.widgetWithText(NavigationDestination, tab)); + await tester.pumpAndSettle(); + } + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -10000)); + await tester.pumpAndSettle(); + expect(find.text(iranBase), findsNothing); + expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing); + expect(find.byKey(const Key('catalog-api-selector')), findsNothing); + await tester.pageBack(); + await tester.pumpAndSettle(); + final iranRequests = network.requests + .where((r) => r.uri.origin == 'https://reelbox.dartty.top') + .toList(); + expect(iranRequests.any((r) => r.uri.path.contains('/first/')), isTrue); + expect( + iranRequests.any((r) => r.uri.path.contains('/movie/by/filtres/')), + isTrue, + ); + expect( + iranRequests.any( + (r) => r.uri.queryParameters['action'] == 'search-trand', + ), + isTrue, + ); + expect(state.saved.single.title, 'Saved title'); + await tester.tap(find.widgetWithText(NavigationDestination, 'Library')); + await tester.pumpAndSettle(); + expect(find.text('Watchlist (1)'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/api_service_test.dart b/test/api_service_test.dart new file mode 100644 index 0000000..40d5838 --- /dev/null +++ b/test/api_service_test.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; + +class StubAdapter implements HttpClientAdapter { + final FutureOr Function(RequestOptions) handler; + final requests = []; + final bodies = []; + StubAdapter(this.handler); + @override + Future fetch( + RequestOptions options, + Stream? body, + Future? cancelFuture, + ) async { + requests.add(options); + if (body != null) { + bodies.add(utf8.decode(await body.expand((chunk) => chunk).toList())); + } + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +ResponseBody jsonBody(dynamic data, {int status = 200}) => + ResponseBody.fromString( + jsonEncode(data), + status, + headers: { + Headers.contentTypeHeader: ['application/json'], + }, + ); + +void main() { + test( + 'observed search IDs take precedence over unrelated posted_id values', + () { + final api = ApiService(); + final raw = jsonDecode( + File('test/fixtures/search.json').readAsStringSync(), + ); + final titles = api.decodeTitles(raw); + expect(titles.map((title) => title.id).toList(), [32789, 18602]); + expect(titles.first.year, 2026); + expect(titles.first.imdb, 10); + expect(titles.first.raw['posted_id'], '2324949'); + expect(titles.first.image, raw[0]['image']); + for (final key in ['posters', 'data', 'result', 'items']) { + expect(api.decodeTitles({key: raw}).map((title) => title.id), [ + 32789, + 18602, + ]); + } + expect(api.decodeTitles(null), isEmpty); + expect( + api.decodeTitles([ + null, + 12, + {}, + {'id': '1', 'title': ''}, + ]), + isEmpty, + ); + expect( + () => api.decodeTitles({'unexpected': 'shape'}), + throwsA(isA()), + ); + }, + ); + test('search, recommendation, trending and actors use observed queries; removed public lists never request', () async { + final raw = jsonDecode( + File('test/fixtures/search.json').readAsStringSync(), + ); + final adapter = StubAdapter((request) { + if (request.uri.queryParameters['action'] == 'search-trand') { + return jsonBody([ + 'Avatar', + {'title': 'Dune'}, + {'q': 'Lost'}, + null, + {}, + ]); + } + return jsonBody(raw); + }); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + expect((await api.search('A & ب', page: 2)).first.id, 32789); + await api.search('Avatar', english: true); + await api.recommended(3, page: 2); + await api.bestRandom(); + await api.bestRandom(byYear: true, page: 3); + expect(await api.trending(), ['Avatar', 'Dune', 'Lost']); + await api.actors(page: 2); + await api.actors(search: ' Tom & ب ', page: 3); + expect(await api.playlists(page: 2), isEmpty); + expect(await api.playlistItems(3328, page: 3), isEmpty); + expect(adapter.requests.map((r) => r.uri.path).toList(), [ + '/api-user/newapi/like.php', + '/api-user/newapi/like.php', + '/api-user/newapi/filter.php', + '/api-user/newapi/like.php', + '/api-user/newapi/like.php', + '/api-user/newapi/like.php', + '/api-user/newapi/like.php', + '/api-user/filmahang/star.php', + ]); + expect(adapter.requests.map((r) => r.uri.queryParameters).toList(), [ + {'action': 'search-movie', 'q': 'A & ب', 'pageno': '2'}, + {'action': 'search-movie-en', 'q': 'Avatar', 'pageno': '1'}, + {'action': 'pishnahad', 'garid': '3', 'pageno': '2'}, + {'action': 'best-rand-top', 'pageno': '1'}, + {'action': 'best-rand-year', 'pageno': '3'}, + {'action': 'search-trand', 'pageno': '1'}, + {'action': 'all-actor', 'pageno': '2'}, + {'action': 'starsearch', 'q': 'Tom & ب', 'pageno': '3'}, + ]); + expect( + adapter.requests.every((request) => request.method == 'GET'), + isTrue, + ); + await expectLater( + api.playlists(mine: true, userId: '1'), + throwsA(isA()), + ); + expect(adapter.requests, hasLength(8)); + }); + test('real curated parallel arrays flatten and rewrite allowlisted legacy links safely', () async { + final raw = jsonDecode( + File('test/fixtures/curated.json').readAsStringSync(), + ); + final adapter = StubAdapter( + (request) => request.uri.path.endsWith('filter.php') + ? jsonBody( + jsonDecode(File('test/fixtures/search.json').readAsStringSync()), + ) + : jsonBody(raw), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + final links = await api.curatedLinks(); + expect(links.single['title'], 'ترسناک'); + expect( + links.single['link'], + 'https://reelbox.dartty.top/api-user/newapi/filter.php?action=pishnahad&garid=1&pageno=', + ); + await api.curatedLinks(best: true); + final titles = await api.curatedContent( + links.single['link'] as String, + page: 3, + ); + expect(titles.first.id, 32789); + expect(adapter.requests[0].uri.path, '/api-user/newapi/pishnahad.php'); + expect(adapter.requests[1].uri.path, '/api-user/newapi/bartar.php'); + expect(adapter.requests[2].uri.queryParameters['pageno'], '3'); + expect(adapter.requests[2].uri.queryParametersAll['pageno'], ['3']); + await expectLater( + api.curatedContent('https://evil.example/api-user/newapi/filter.php'), + throwsA(isA()), + ); + }); + test( + 'public catalog endpoints use public tokens and preserve zero-based pages', + () async { + final movie = jsonDecode( + File('test/fixtures/movie.json').readAsStringSync(), + ); + final season = jsonDecode( + File('test/fixtures/season.json').readAsStringSync(), + ); + final adapter = StubAdapter((request) { + final path = request.uri.path; + if (path.contains('/first/')) { + return jsonBody({ + 'posters': [movie], + 'genres': [], + }); + } + if (path.contains('/all/')) { + return jsonBody([ + {'id': 3, 'title': 'انیمیشن + انیمه'}, + ]); + } + if (path.contains('/season/')) return jsonBody([season]); + if (path.contains('/movie/by/35721/')) return jsonBody(movie); + if (path.contains('/channel/by/17/')) { + return jsonBody({...movie, 'id': 17, 'type': 'channel'}); + } + return jsonBody([movie]); + }); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + expect((await api.home())['posters'][0]['id'], 35721); + expect((await api.genres()).single.id, 3); + expect((await api.countries()).single.id, 3); + expect(await api.categories(), isEmpty); + expect((await api.catalog()).single.id, 35721); + await api.catalog(kind: 'serie', genre: 3, order: 'imdb', page: 2); + await api.catalog( + kind: 'mixed', + genre: 2, + country: 9, + order: 'year', + page: 1, + ); + await api.catalog(country: 9); + expect(await api.channels(category: 17, country: 1, page: 2), isEmpty); + expect((await api.poster(35721)).id, 35721); + await expectLater(api.channel(17), throwsA(isA())); + expect((await api.seasons(1003)).single.episodes.first.id, 72819); + await api.actorTitles(582); + await api.roles(35721); + await api.subtitles('movie', 35721); + await api.subtitles('episode', 72819); + await api.comments(35721); + const k = 'public'; + expect(adapter.requests.map((r) => r.uri.path).toList(), [ + '/api/first/$k/', + '/api/genre/all/$k/', + '/api/country/all/$k/', + '/api/movie/by/filtres/0/created/0/$k/', + '/api/serie/by/filtres/3/imdb/2/$k/', + '/api/poster/by/filtres/2/9/year/1/$k/', + '/api/poster/by/filtres/0/9/created/0/$k/', + '/api/movie/by/35721/$k/', + '/api/season/by/serie/1003/$k/', + '/api/movie/by/actor/582/$k/', + '/api/role/by/poster/35721/$k/', + '/api/subtitles/by/movie/35721/$k/', + '/api/subtitles/by/episode/72819/$k/', + ]); + }, + ); + test( + 'invalid catalog kinds and subtitle kinds cannot inject routes', + () async { + final adapter = StubAdapter((_) => jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await expectLater( + api.catalog(kind: '../user'), + throwsA(isA()), + ); + await expectLater( + api.subtitles('../user', 1), + throwsA(isA()), + ); + expect(adapter.requests, isEmpty); + }, + ); + test( + 'rejects untrusted, insecure and ambiguous routes before transport', + () async { + final adapter = StubAdapter((_) => jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + for (final path in [ + 'https://attacker.example/api/', + 'http://ninwinios.info/api/first/', + '//attacker.example/api/', + 'https://ninwinios.info@attacker.example/api/', + 'https://user:secret@ninwinios.info/api/', + 'https://ninwinios.info:444/api/', + '../private', + '/api/../private', + '/api/%2e%2e/private', + r'\evil\api', + 'genre/all/#secret', + 'file:///api/first', + ]) { + await expectLater( + api.get(path), + throwsA(isA()), + reason: path, + ); + } + expect(adapter.requests, isEmpty); + }, + ); + test( + 'rewrites only allowlisted legacy public links to HTTPS current host', + () async { + final adapter = StubAdapter((_) => jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await api.get( + 'http://simbaapp.info/api-user/newapi/filter.php?action=best&x=a%2Fb', + ); + expect( + adapter.requests.single.uri.toString(), + 'https://reelbox.dartty.top/api-user/newapi/filter.php?action=best&x=a%2Fb', + ); + }, + ); + test( + 'base updates are atomic, allow HTTPS service and local HTTP tests only', + () async { + final api = ApiService(); + for (final url in [ + 'http://ninwinios.info/api/', + 'https://attacker.example/api/', + 'https://user:secret@ninwinios.info/api/', + 'https://ninwinios.info/other/', + ]) { + await expectLater(api.setBaseUrl(url), throwsA(isA())); + expect(api.baseUrl, ApiService.defaultBaseUrl); + } + await api.setBaseUrl('http://127.0.0.1:8080/api'); + expect(api.baseUrl, 'http://127.0.0.1:8080/api/'); + expect( + () => ApiService(baseUrl: 'http://evil.example/api/'), + throwsA(isA()), + ); + }, + ); + test( + 'generic POST is form encoded and never follows credential redirects', + () async { + final adapter = StubAdapter((request) { + expect(request.followRedirects, isFalse); + expect(request.maxRedirects, 0); + return ResponseBody.fromString( + '', + 307, + headers: { + 'location': ['https://evil.example/collect'], + }, + ); + }); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + try { + await api.post('test-only', { + 'password': 'never-log-me', + 'comment': 'a & ب', + }); + fail('Redirect must not succeed'); + } on ApiException catch (error) { + expect(error.statusCode, 307); + expect(error.toString(), isNot(contains('never-log-me'))); + expect(error.toString(), isNot(contains('test-only'))); + } + expect(adapter.requests, hasLength(1)); + expect( + adapter.requests.single.contentType, + Headers.formUrlEncodedContentType, + ); + expect(Uri.splitQueryString(adapter.bodies.single), { + 'password': 'never-log-me', + 'comment': 'a & ب', + }); + }, + ); + test( + 'removed discovery ignores advertisements and inherits no credentials', + () async { + for (final candidate in [ + 'http://ninwinios.info/api/', + 'https://attacker.example/api/', + ]) { + final adapter = StubAdapter((request) { + if (request.uri.host == 'ninnies-server.info') { + return ResponseBody.fromString(candidate, 200); + } + return jsonBody([ + {'id': 1, 'title': 'ترسناک'}, + ]); + }); + final dio = Dio(BaseOptions(headers: {'Authorization': 'secret'})) + ..httpClientAdapter = adapter; + final api = ApiService(dio: dio); + await api.initialize(); + expect(api.baseUrl, ApiService.defaultBaseUrl); + expect(adapter.requests, isEmpty); + await api.genres(); + expect( + adapter.requests.first.uri.toString(), + '${ApiService.iranBaseUrl}genre/all/public/', + ); + expect( + adapter.requests.every( + (request) => !request.headers.containsKey('Authorization'), + ), + isTrue, + ); + expect( + adapter.requests.any( + (request) => request.uri.host == 'attacker.example', + ), + isFalse, + ); + } + }, + ); + test( + 'HTML, malformed JSON, timeout and HTTP denials produce safe errors', + () async { + for (final status in [200, 401, 403, 500]) { + final adapter = StubAdapter( + (_) => + ResponseBody.fromString('private details', status), + ); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + await expectLater( + api.get('private/password-secret'), + throwsA( + isA().having( + (error) => error.toString(), + 'sanitized message', + isNot(contains('password-secret')), + ), + ), + ); + } + final adapter = StubAdapter( + (request) => throw DioException.connectionTimeout( + timeout: const Duration(seconds: 1), + requestOptions: request, + ), + ); + await expectLater( + ApiService(dio: Dio()..httpClientAdapter = adapter).get('secret'), + throwsA(isA()), + ); + }, + ); + test( + 'HTTPS relative route uses timeouts, public marker and JSON decoding', + () async { + final adapter = StubAdapter( + (_) => jsonBody([ + {'id': 1, 'title': 'ترسناک'}, + ]), + ); + final dio = Dio()..httpClientAdapter = adapter; + final api = ApiService(dio: dio); + await api.initialize(discover: false); + final result = await api.get('genre/all/${ApiService.sharedKey}/'); + expect(result[0]['id'], 1); + expect(ApiService.sharedKey, 'public'); + expect(api.baseUrl, ApiService.iranBaseUrl); + expect(api.hostRoot, 'https://reelbox.dartty.top'); + final request = adapter.requests.single; + expect( + request.uri.toString(), + '${api.baseUrl}genre/all/${ApiService.sharedKey}/', + ); + expect(request.connectTimeout, isNotNull); + expect(request.receiveTimeout, isNotNull); + expect(request.sendTimeout, isNotNull); + expect(request.followRedirects, isFalse); + }, + ); +} diff --git a/test/app_state_test.dart b/test/app_state_test.dart new file mode 100644 index 0000000..bb0486a --- /dev/null +++ b/test/app_state_test.dart @@ -0,0 +1,486 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _sourceUrl = 'https://media.example/video%2F17.mp4?token=a%2Bb&part=1'; + +TitleRecord _title(int id) => TitleRecord.fromJson({ + 'id': id, + 'type': 'serie', + 'title': 'Title $id', + 'sources': [ + {'id': 520531, 'quality': '1080p', 'type': 'mp4', 'url': _sourceUrl}, + ], + 'metadata': {'original': 'محلی'}, +}); + +class _LocalApi extends ApiService { + int initializeCalls = 0; + bool? lastDiscover; + + @override + Future initialize({bool discover = true}) async { + initializeCalls++; + lastDiscover = discover; + } + + @override + Future get(String path, {Map? query}) => + throw StateError('Local state must not request catalog data'); + + @override + Future post(String path, Map form) => + throw StateError('Local state must not mutate network state'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test( + 'watchlist and settings persist locally with exact source metadata', + () async { + final api = _LocalApi(); + final store = await SharedPreferences.getInstance(); + final state = AppState(api: api, storage: store); + await state.initialize(discover: false); + expect(api.initializeCalls, 1); + expect(api.lastDiscover, isFalse); + var notifications = 0; + state.addListener(() => notifications++); + final title = _title(17); + await state.toggleSaved(title); + expect(state.hasSaved(17), isTrue); + expect(state.saved.single.sources.single.url, _sourceUrl); + await state.updateSettings( + state.prefs.copyWith(locale: 'fa', preferredGenres: [3]), + ); + expect(state.locale, 'fa'); + expect(state.selectedGenres, [3]); + expect(notifications, 2); + final restored = AppState(api: _LocalApi(), storage: store); + await restored.initialize(discover: false); + expect(restored.saved.single.toJson(), title.toJson()); + expect(restored.prefs.toJson(), state.prefs.toJson()); + expect(() => restored.saved.clear(), throwsUnsupportedError); + await restored.toggleSaved(title); + expect(restored.saved, isEmpty); + await restored.toggleSaved(title); + await restored.removeSaved(17); + expect(restored.hasSaved(17), isFalse); + state.dispose(); + restored.dispose(); + }, + ); + + test( + 'episode resume is isolated, updates reorder and removals are exact', + () async { + final state = AppState(api: _LocalApi()); + await state.initialize(discover: false); + final title = _title(9); + await state.recordWatch(title, positionSeconds: 5, durationSeconds: 100); + await state.recordWatch( + title, + episodeId: 101, + episodeTitle: 'Episode 1', + sourceUrl: _sourceUrl, + positionSeconds: 25, + durationSeconds: 100, + ); + await state.recordWatch( + title, + episodeId: 102, + episodeTitle: 'Episode 2', + positionSeconds: 75, + durationSeconds: 100, + ); + expect(state.resumeSeconds(9), 5); + expect(state.resumeSeconds(9, episodeId: 101), 25); + expect(state.resumeSeconds(9, episodeId: 102), 75); + expect(state.resumeSeconds(10, episodeId: 101), 0); + expect(state.history.first.progress, 0.75); + await state.recordWatch( + title, + episodeId: 101, + episodeTitle: 'Episode 1', + sourceUrl: _sourceUrl, + positionSeconds: 40, + durationSeconds: 100, + ); + expect(state.history.length, 3); + expect(state.history.first.episodeId, 101); + expect(state.history.first.sourceUrl, _sourceUrl); + expect(state.history.first.updatedAt.isUtc, isTrue); + final restored = AppState(api: _LocalApi()); + await restored.initialize(discover: false); + expect(restored.resumeSeconds(9, episodeId: 101), 40); + expect(restored.history.first.episodeTitle, 'Episode 1'); + expect(restored.history.first.sourceUrl, _sourceUrl); + expect(restored.history.first.toJson(), state.history.first.toJson()); + await state.updateSettings(state.prefs.copyWith(resumePlayback: false)); + expect(state.resumeSeconds(9, episodeId: 101), 0); + expect(state.history.first.positionSeconds, 40); + await state.removeHistory(9, episodeId: 101); + expect(state.history.map((entry) => entry.episodeId), [102, null]); + await state.removeHistory(9); + expect(state.history.single.episodeId, 102); + await state.clearHistory(); + expect(state.history, isEmpty); + state.dispose(); + restored.dispose(); + }, + ); + + test( + 'watch progress clamps safely and invalid positions reject without changes', + () async { + final state = AppState(api: _LocalApi()); + await state.recordWatch( + _title(1), + positionSeconds: 200, + durationSeconds: 100, + ); + expect(state.history.single.progress, 1); + await state.recordWatch(_title(2)); + expect(state.history.first.progress, 0); + await expectLater( + state.recordWatch(_title(1), positionSeconds: double.nan), + throwsArgumentError, + ); + await expectLater( + state.recordWatch(_title(1), durationSeconds: -1), + throwsArgumentError, + ); + expect(state.history.length, 2); + state.dispose(); + }, + ); + + test( + 'search history trims, deduplicates case-insensitively and caps at 30', + () async { + final state = AppState(api: _LocalApi()); + await state.rememberSearch(' Dune '); + await state.rememberSearch('Alien'); + await state.rememberSearch('dUnE'); + await state.rememberSearch(' '); + expect(state.searchHistory, ['dUnE', 'Alien']); + await state.removeSearch(' DUNE '); + expect(state.searchHistory, ['Alien']); + await Future.wait( + List.generate(35, (i) => state.rememberSearch('Query $i')), + ); + expect(state.searchHistory.length, 30); + expect(state.searchHistory.first, 'Query 34'); + expect(state.searchHistory.last, 'Query 5'); + final restored = AppState(api: _LocalApi()); + await restored.initialize(discover: false); + expect(restored.searchHistory, state.searchHistory); + await restored.clearSearchHistory(); + expect(restored.searchHistory, isEmpty); + state.dispose(); + restored.dispose(); + }, + ); + + test( + 'local collections have stable IDs and independent title membership', + () async { + final state = AppState(api: _LocalApi()); + final first = await state.createCollection(' Weekend '); + final second = await state.createCollection('Weekend'); + expect(first.name, 'Weekend'); + expect(first.id, isNot(second.id)); + await state.toggleCollectionTitle(first.id, _title(17)); + await state.toggleCollectionTitle(second.id, _title(17)); + await state.toggleCollectionTitle(first.id, _title(18)); + expect(state.collections.first.titles.map((title) => title.id), [17, 18]); + expect(state.saved, isEmpty); + await state.toggleCollectionTitle(first.id, _title(17)); + expect(state.collections.first.titles.single.id, 18); + expect(state.collections.last.titles.single.id, 17); + expect( + () => state.collections.first.titles.clear(), + throwsUnsupportedError, + ); + final restored = AppState(api: _LocalApi()); + await restored.initialize(discover: false); + expect( + restored.collections.map((item) => item.toJson()), + state.collections.map((item) => item.toJson()), + ); + await restored.deleteCollection(first.id); + expect(restored.collections.single.id, second.id); + await expectLater( + restored.toggleCollectionTitle('missing', _title(1)), + throwsStateError, + ); + await expectLater(restored.createCollection(' '), throwsArgumentError); + state.dispose(); + restored.dispose(); + }, + ); + + test( + 'versioned export/import replaces the entire library and persists it', + () async { + final state = AppState(api: _LocalApi()); + await state.toggleSaved(_title(17)); + await state.recordWatch( + _title(17), + episodeId: 41, + episodeTitle: 'Forty one', + sourceUrl: _sourceUrl, + positionSeconds: 12.5, + durationSeconds: 50, + ); + await state.rememberSearch('سینما'); + final collection = await state.createCollection('Favorites'); + await state.toggleCollectionTitle(collection.id, _title(20)); + await state.updateSettings( + state.prefs.copyWith(locale: 'fa', playbackSpeed: 2), + ); + final exported = state.exportLibrary(); + final document = jsonDecode(exported) as Map; + expect(document['format'], 'reelbox.library'); + expect(document['version'], 1); + final imported = AppState(api: _LocalApi()); + await imported.toggleSaved(_title(99)); + var notifications = 0; + imported.addListener(() => notifications++); + await imported.importLibrary(exported); + expect(imported.exportLibrary(), exported); + expect(imported.hasSaved(99), isFalse); + expect(imported.resumeSeconds(17, episodeId: 41), 12.5); + expect(imported.collections.single.id, collection.id); + expect(notifications, 1); + final restored = AppState(api: _LocalApi()); + await restored.initialize(discover: false); + expect(restored.exportLibrary(), exported); + state.dispose(); + imported.dispose(); + restored.dispose(); + }, + ); + + test( + 'corrupt import is atomic across memory, storage and notifications', + () async { + final store = await SharedPreferences.getInstance(); + final state = AppState(api: _LocalApi(), storage: store); + await state.toggleSaved(_title(17)); + await state.recordWatch(_title(17), episodeId: 2, positionSeconds: 3); + await state.rememberSearch('Keep'); + final original = state.exportLibrary(); + final stored = store.getString(AppState.storageKey); + var notifications = 0; + state.addListener(() => notifications++); + final mutations = )>[ + (doc) => doc['version'] = 2, + (doc) => doc['version'] = '1', + (doc) => doc['format'] = 'other', + (doc) => doc.remove('saved'), + (doc) => doc['settings'] = [], + (doc) => doc['saved'] = [ + {'id': '17', 'title': 'Invalid'}, + ], + (doc) => doc['saved'] = [doc['saved'][0], doc['saved'][0]], + (doc) => doc['history'][0]['positionSeconds'] = -1, + (doc) => doc['history'][0]['updatedAt'] = 'not a date', + (doc) => doc['searchHistory'] = [42], + (doc) => doc['collections'] = [ + {'id': '', 'name': 'Invalid', 'titles': []}, + ], + ]; + for (final mutate in mutations) { + final document = jsonDecode(original) as Map; + mutate(document); + await expectLater( + state.importLibrary(jsonEncode(document)), + throwsFormatException, + ); + expect(state.exportLibrary(), original); + expect(store.getString(AppState.storageKey), stored); + } + for (final text in ['not json', '[]', 'null', '{']) { + await expectLater(state.importLibrary(text), throwsFormatException); + } + expect(notifications, 0); + await state.rememberSearch('Still usable'); + expect(state.searchHistory.first, 'Still usable'); + state.dispose(); + }, + ); + + test( + 'import limits history to latest 1000 and normalizes 30 searches', + () async { + final state = AppState(api: _LocalApi()); + final document = + jsonDecode(state.exportLibrary()) as Map; + final start = DateTime.utc(2026); + document['history'] = List.generate( + 1005, + (i) => WatchEntry( + title: _title(i + 1), + episodeId: 1, + positionSeconds: i.toDouble(), + updatedAt: start.add(Duration(seconds: i)), + ).toJson(), + ); + document['searchHistory'] = [ + ' Dune ', + 'dune', + '', + ...List.generate(35, (i) => 'Query $i'), + ]; + await state.importLibrary(jsonEncode(document)); + expect(state.history.length, 1000); + expect(state.history.first.title.id, 1005); + expect(state.history.last.title.id, 6); + expect(state.searchHistory.length, 30); + expect(state.searchHistory.first, 'Dune'); + await state.recordWatch(_title(2000), episodeId: 7, positionSeconds: 20); + expect(state.history.length, 1000); + expect(state.history.first.title.id, 2000); + expect(state.history.last.title.id, 7); + state.dispose(); + }, + ); + + test('clear resets preferences and all local state without clearing unrelated keys', () async { + final store = await SharedPreferences.getInstance(); + await store.setString('unrelated.plugin.setting', 'keep'); + final state = AppState(api: _LocalApi(), storage: store); + await state.toggleSaved(_title(1)); + await state.recordWatch(_title(1), positionSeconds: 5); + await state.rememberSearch('Query'); + final collection = await state.createCollection('One'); + await state.toggleCollectionTitle(collection.id, _title(1)); + await state.updateSettings(state.prefs.copyWith(locale: 'fa')); + await state.clearLocalData(); + expect(state.prefs.toJson(), AppSettings().toJson()); + expect(state.saved, isEmpty); + expect(state.history, isEmpty); + expect(state.searchHistory, isEmpty); + expect(state.collections, isEmpty); + expect(store.getString('unrelated.plugin.setting'), 'keep'); + final restored = AppState(api: _LocalApi(), storage: store); + await restored.initialize(discover: false); + expect(restored.exportLibrary(), state.exportLibrary()); + state.dispose(); + restored.dispose(); + }); + + test('settings defaults, copyWith and JSON preserve every preference', () { + final defaults = AppSettings(); + expect(defaults.locale, 'en'); + expect(defaults.theme, 'dark'); + expect(defaults.autoNext, isTrue); + expect(defaults.resumePlayback, isTrue); + expect(defaults.playbackSpeed, 1.0); + expect(defaults.skipSeconds, 10); + expect(defaults.introCompleted, isFalse); + final changed = defaults.copyWith( + introCompleted: true, + locale: 'fa', + theme: 'light', + wifiOnlyDownloads: true, + autoNext: false, + resumePlayback: false, + autoplay: true, + externalPlayer: true, + defaultQuality: '1080p', + playbackSpeed: 1.5, + skipSeconds: 30, + subtitleFontSize: 32, + subtitleColor: 0xFF00FF00, + subtitleBackground: 0x40000000, + subtitleDelay: -1.25, + audioDelay: 0.5, + orientation: 'landscape', + preferredGenres: [3, 7], + hideTrailers: true, + downloadNotifications: false, + dataSaver: true, + ); + final restored = AppSettings.fromJson( + jsonDecode(jsonEncode(changed.toJson())) as Map, + ); + expect(restored.toJson(), changed.toJson()); + expect(defaults.locale, 'en'); + expect(restored.copyWith().toJson(), restored.toJson()); + expect(() => restored.preferredGenres.add(4), throwsUnsupportedError); + }); + + for (final introCompleted in [null, false, true]) { + test( + 'stored onboarding flag $introCompleted migrates without losing the library', + () async { + final store = await SharedPreferences.getInstance(); + final state = AppState(api: _LocalApi(), storage: store); + final restored = AppState(api: _LocalApi(), storage: store); + addTearDown(state.dispose); + addTearDown(restored.dispose); + final document = + jsonDecode(state.exportLibrary()) as Map; + final settings = document['settings'] as Map; + settings['locale'] = 'fa'; + settings['playbackSpeed'] = 1.5; + if (introCompleted == null) { + settings.remove('introCompleted'); + } else { + settings['introCompleted'] = introCompleted; + } + document['saved'] = [_title(17).toJson()]; + await store.setString(AppState.storageKey, jsonEncode(document)); + await state.initialize(discover: false); + expect(state.prefs.introCompleted, introCompleted ?? true); + expect(state.locale, 'fa'); + expect(state.prefs.playbackSpeed, 1.5); + expect(state.saved.single.toJson(), _title(17).toJson()); + final persisted = jsonDecode( + store.getString(AppState.storageKey)!, + ) as Map; + expect(persisted['settings']['introCompleted'], introCompleted ?? true); + await restored.initialize(discover: false); + expect(restored.exportLibrary(), state.exportLibrary()); + }, + ); + } + + test('settings parser defaults invalid fields without type errors', () { + final prefs = AppSettings.fromJson({ + 'locale': 'xx', + 'theme': 7, + 'autoplay': 'yes', + 'playbackSpeed': -1, + 'skipSeconds': 0, + 'subtitleFontSize': 'bad', + 'subtitleDelay': double.infinity, + 'audioDelay': null, + 'orientation': 'diagonal', + 'preferredGenres': [3, '7', null, -1, 3], + }); + expect(prefs.locale, 'en'); + expect(prefs.theme, 'dark'); + expect(prefs.autoplay, isFalse); + expect(prefs.playbackSpeed, 1); + expect(prefs.skipSeconds, 10); + expect(prefs.subtitleFontSize, 24); + expect(prefs.subtitleDelay, 0); + expect(prefs.audioDelay, 0); + expect(prefs.orientation, 'auto'); + expect(prefs.preferredGenres, [3]); + // Existing serialized settings predate onboarding and skip it on migration. + expect( + AppSettings.fromJson({}).toJson(), + AppSettings(introCompleted: true).toJson(), + ); + }); +} diff --git a/test/artwork_data_saver_test.dart b/test/artwork_data_saver_test.dart new file mode 100644 index 0000000..0174558 --- /dev/null +++ b/test/artwork_data_saver_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/common.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets( + 'data saver never builds network images and retains artwork placeholder', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState(); + await state.updateSettings(state.prefs.copyWith(dataSaver: true)); + await tester.pumpWidget( + AppScope( + state: state, + child: const MaterialApp( + home: Scaffold( + body: MediaArtwork( + url: 'https://example.test/poster.jpg', + width: 100, + height: 140, + ), + ), + ), + ), + ); + expect(find.byType(Image), findsNothing); + expect(find.byIcon(Icons.local_movies_outlined), findsOneWidget); + final size = tester.getSize(find.byType(MediaArtwork)); + expect(size, const Size(100, 140)); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }, + ); +} diff --git a/test/bootstrap_test.dart b/test/bootstrap_test.dart new file mode 100644 index 0000000..7f23d51 --- /dev/null +++ b/test/bootstrap_test.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/bootstrap.dart'; + +void main() { + testWidgets('shows initialization progress before opening the real app', ( + tester, + ) async { + final ready = Completer(); + var calls = 0; + await tester.pumpWidget( + ReelboxBootstrap( + initialize: () { + calls++; + return ready.future; + }, + builder: (_) => const MaterialApp(home: Text('Application ready')), + ), + ); + expect(calls, 1); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Application ready'), findsNothing); + ready.complete(); + await tester.pumpAndSettle(); + expect(find.text('Application ready'), findsOneWidget); + }); + testWidgets( + 'initialization failure is recoverable and never displays raw vendor text', + (tester) async { + var calls = 0; + await tester.pumpWidget( + ReelboxBootstrap( + initialize: () async { + if (calls++ == 0) throw StateError('نورتون https://t.me/support'); + }, + builder: (_) => const MaterialApp(home: Text('Application ready')), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Try again'), findsOneWidget); + expect(find.textContaining('نورتون'), findsNothing); + expect(find.textContaining('t.me'), findsNothing); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(calls, 2); + expect(find.text('Application ready'), findsOneWidget); + }, + ); + testWidgets('late initialization completion does not update disposed UI', ( + tester, + ) async { + final ready = Completer(); + await tester.pumpWidget( + ReelboxBootstrap( + initialize: () => ready.future, + builder: (_) => const MaterialApp(home: Text('Application ready')), + ), + ); + await tester.pumpWidget(const SizedBox.shrink()); + ready.complete(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/cast_media_share_test.dart b/test/cast_media_share_test.dart new file mode 100644 index 0000000..914f25f --- /dev/null +++ b/test/cast_media_share_test.dart @@ -0,0 +1,294 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/cast_media_share.dart'; +import 'package:reelbox/media/segmented_download.dart'; + +void main() { + final original = { + 'url': 'https://authorized.invalid/synthetic.mp4', + 'title': 'Synthetic fixture (not a decoder test)', + 'contentType': 'video/mp4', + 'positionMs': 1250, + 'subtitleUrl': 'https://authorized.invalid/not-webvtt.srt', + }; + + late Directory root; + setUp(() async { + root = await Directory.systemTemp.createTemp('cast-share-test-'); + }); + tearDown(() async { + await root.delete(recursive: true); + }); + + test('explicit local-file lease serves only the exact file and revokes without deleting media', () async { + // Synthetic bytes exercise HTTP ownership, not audiovisual decoding. + final video = await File('${root.path}/synthetic.mp4') + .writeAsBytes([0, 1, 2, 3, 4, 5]); + await File('${root.path}/private.txt').writeAsString('do not share'); + final lease = await CastMediaShare.prepare( + payload: original, + localFile: video.path, + bindAddress: InternetAddress.loopbackIPv4, + ); + addTearDown(lease.close); + final url = Uri.parse(lease.payload['url'] as String); + expect(url.scheme, 'http'); + expect(url.pathSegments.first.length, greaterThanOrEqualTo(32)); + expect((await fetch(url)).bytes, [0, 1, 2, 3, 4, 5]); + expect((await fetch(url, range: 'bytes=2-4')).bytes, [2, 3, 4]); + for (final forbidden in [ + url.resolve('./'), + url.resolve('private.txt'), + url.resolve('package.json'), + url.replace(path: '/media.mp4'), + ]) { + expect((await fetch(forbidden)).status, 404); + } + expect(lease.payload['title'], original['title']); + expect(lease.payload['positionMs'], 1250); + expect(original['url'], startsWith('https://')); + await Future.wait([lease.close(), lease.close()]); + await expectLater(fetch(url), throwsA(isA())); + expect(await video.readAsBytes(), [0, 1, 2, 3, 4, 5]); + expect( + await File('${root.path}/private.txt').readAsString(), + 'do not share', + ); + }); + + test('remote video plus selected text serves actual UTF8 WebVTT only and deletes disposable files', () async { + final before = await sessionTemps(); + final lease = await CastMediaShare.prepare( + payload: original, + subtitleText: '1\n00:00:01,000 --> 00:00:02,000\nسلام', + bindAddress: InternetAddress.loopbackIPv4, + ); + addTearDown(lease.close); + expect(lease.payload['url'], original['url']); + final url = Uri.parse(lease.payload['subtitleUrl'] as String); + final response = await fetch(url); + expect(response.type, startsWith('text/vtt')); + expect( + utf8.decode(response.bytes), + 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nسلام\n\n', + ); + final owned = (await sessionTemps()).difference(before); + expect(owned, hasLength(1)); + expect(owned.single.startsWith(root.path), isFalse); + expect( + (await Directory(owned.single).list().toList()).single.path, + endsWith('.vtt'), + ); + await lease.close(); + expect(await sessionTemps(), before); + await expectLater(fetch(url), throwsA(isA())); + }); + + test('local file and selected text share separate actual VTT and media resources', () async { + final video = await File('${root.path}/synthetic.webm') + .writeAsString('synthetic media fixture'); + final lease = await CastMediaShare.prepare( + payload: original, + localFile: video.path, + subtitleText: 'WEBVTT\n\n00:00.000 --> 00:01.000\ncaption', + bindAddress: InternetAddress.loopbackIPv4, + ); + addTearDown(lease.close); + final videoUri = Uri.parse(lease.payload['url'] as String); + final subUri = Uri.parse(lease.payload['subtitleUrl'] as String); + expect(subUri.pathSegments.first, videoUri.pathSegments.first); + expect(subUri, isNot(videoUri)); + expect(utf8.decode((await fetch(subUri)).bytes), contains('\ncaption\n')); + await lease.close(); + expect(await video.exists(), isTrue); + }); + + test('invalid local selections fail rather than quietly loading the remote fallback', () async { + final empty = await File('${root.path}/empty.mp4').create(); + final unsupported = await File('${root.path}/notes.txt') + .writeAsString('not video'); + for (final path in [ + 'relative.mp4', + '${root.path}/missing.mp4', + root.path, + empty.path, + unsupported.path, + ]) { + await expectLater( + CastMediaShare.prepare( + payload: original, + localFile: path, + bindAddress: InternetAddress.loopbackIPv4, + ), + throwsA( + anyOf( + isA(), + isA(), + isA(), + ), + ), + ); + } + }); + + test('unsupported selected subtitles and failed server preparation leave no temp data', () async { + final before = await sessionTemps(); + await expectLater( + CastMediaShare.prepare( + payload: original, + subtitleText: 'unsupported', + bindAddress: InternetAddress.loopbackIPv4, + ), + throwsFormatException, + ); + expect(await sessionTemps(), before); + await expectLater( + CastMediaShare.prepare( + payload: original, + subtitleText: '1\n00:00:00,000 --> 00:00:01,000\nSynthetic', + bindAddress: InternetAddress.loopbackIPv4, + advertisedHost: '8.8.8.8', + ), + throwsArgumentError, + ); + expect(await sessionTemps(), before); + }); + + test('complete indexed package shares only rewritten manifests and validated resources', () async { + final package = await syntheticPackage(root); + await File('${package.directory}/private.txt').writeAsString('private'); + final lease = await CastMediaShare.prepare( + payload: original, + localFile: package.path, + bindAddress: InternetAddress.loopbackIPv4, + ); + addTearDown(lease.close); + final uri = Uri.parse(lease.payload['url'] as String); + expect(uri.scheme, 'http'); + expect( + utf8.decode((await fetch(uri)).bytes), + package.manifests[package.manifest], + ); + expect( + utf8.decode((await fetch(uri.resolve('playlist_1.m3u8'))).bytes), + contains('resource_0.ts'), + ); + expect( + utf8.decode((await fetch(uri.resolve('resource_0.ts'))).bytes), + 'synthetic resource', + ); + expect((await fetch(uri.resolve('package.json'))).status, 404); + expect((await fetch(uri.resolve('private.txt'))).status, 404); + await lease.close(); + expect(await File(package.path).exists(), true); + expect(await File('${package.directory}/resource_0.ts').exists(), true); + }); + test('damaged package is not advertised as Cast-ready', () async { + final package = await syntheticPackage(root); + await File('${package.directory}/resource_0.ts').writeAsString('corrupt'); + await expectLater( + CastMediaShare.prepare( + payload: original, + localFile: package.path, + bindAddress: InternetAddress.loopbackIPv4, + ), + throwsStateError, + ); + }); + test( + 'remote-only lease copies payload and never passes through an SRT URL', + () async { + final lease = await CastMediaShare.prepare(payload: original); + expect(lease.payload, {...original}..remove('subtitleUrl')); + expect(original['subtitleUrl'], endsWith('.srt')); + await lease.close(); + await lease.close(); + }, + ); + test( + 'public injectable lease constructor closes its resource only once', + () async { + var closed = 0; + final lease = CastMediaLease( + payload: original, + onClose: () async { + closed++; + }, + ); + await Future.wait([lease.close(), lease.close()]); + expect(closed, 1); + expect(lease.payload, original); + expect(identical(lease.payload, original), isFalse); + }, + ); +} + +Future syntheticPackage(Directory root) async { + final dir = await Directory('${root.path}/pkg').create(); + final bytes = utf8.encode('synthetic resource'); + final package = OfflinePackage( + id: 'pkg', + url: 'https://authorized.invalid/root.m3u8', + directory: dir.path, + manifest: 'playlist_0.m3u8', + resources: [ + OfflineResource( + url: 'https://authorized.invalid/segment.ts', + filename: 'resource_0.ts', + bytes: bytes.length, + digest: sha256.convert(bytes).toString(), + ), + ], + manifests: { + 'playlist_0.m3u8': + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nplaylist_1.m3u8\n', + 'playlist_1.m3u8': '#EXTM3U\n#EXTINF:1,\nresource_0.ts\n#EXT-X-ENDLIST\n', + }, + status: 'complete', + ); + await File('${dir.path}/resource_0.ts').writeAsBytes(bytes); + for (final entry in package.manifests.entries) { + await File('${dir.path}/${entry.key}').writeAsString(entry.value); + } + await File('${dir.path}/package.json') + .writeAsString(jsonEncode(package.toJson())); + return package; +} + +Future<({int status, List bytes, String type})> fetch( + Uri uri, { + String? range, +}) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + try { + final request = await client.getUrl(uri); + if (range != null) request.headers.set('range', range); + final response = await request.close(); + final bytes = await response.fold>([], (a, b) => a..addAll(b)); + return ( + status: response.statusCode, + bytes: bytes, + type: response.headers.value('content-type') ?? '', + ); + } finally { + client.close(force: true); + } +} + +Future> sessionTemps() async => + (await Directory.systemTemp + .list(followLinks: false) + .where( + (f) => + f is Directory && + f.uri.pathSegments + .where((s) => s.isNotEmpty) + .last + .startsWith('reelbox-cast-'), + ) + .toList()) + .map((f) => f.path) + .toSet(); diff --git a/test/catalog_discovery_regression_test.dart b/test/catalog_discovery_regression_test.dart new file mode 100644 index 0000000..4b12161 --- /dev/null +++ b/test/catalog_discovery_regression_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/ui/catalog.dart'; + +import 'catalog_widget_test.dart' show CatalogApi, mountCatalog; + +class DiscoveryApi extends CatalogApi { + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => []; + @override + Future>> playlists({ + bool mine = false, + String userId = '', + int page = 1, + }) async => []; + @override + Future>> curatedLinks({bool best = false}) async => + []; + @override + Future> bestRandom({ + bool byYear = false, + int page = 1, + }) async => []; +} + +Future openDirectory(WidgetTester tester) async { + await tester.tap(find.byTooltip('Explore more')); + await tester.pumpAndSettle(); +} + +void main() { + for (final preset in [ + ('Best titles', 'mixed', 0, 'rating'), + ('Newest titles', 'mixed', 0, 'created'), + ('Popular titles', 'mixed', 0, 'rating'), + ('Top 250 movies', 'movie', 0, 'imdb'), + ('Top 250 series', 'serie', 0, 'imdb'), + ('Korean series', 'serie', 36, 'created'), + ('Chinese / Japanese series', 'serie', 37, 'created'), + ('Turkish series', 'serie', 35, 'created'), + ('Indian series', 'serie', 38, 'created'), + ('Indian movies', 'movie', 38, 'created'), + ('Most-viewed movies', 'movie', 0, 'views'), + ('Most-viewed series', 'serie', 0, 'views'), + ]) { + testWidgets( + '${preset.$1} ${preset.$4 == 'views' ? 'is unavailable on Iran' : 'uses the Iran catalog contract'}', + (tester) async { + final api = DiscoveryApi(); + await mountCatalog(tester, api, const DiscoverPage()); + await openDirectory(tester); + if (preset.$4 == 'views') { + final list = tester.widget(find.byType(ListView)); + final children = + (list.childrenDelegate as SliverChildListDelegate).children; + expect( + children.whereType().map( + (tile) => (tile.title as Text).data, + ), + isNot(contains(preset.$1)), + ); + expect(api.catalogCalls, isEmpty); + } else { + await tester.scrollUntilVisible(find.text(preset.$1), 300); + await tester.ensureVisible(find.text(preset.$1)); + await tester.pumpAndSettle(); + await tester.tap(find.text(preset.$1)); + await tester.pumpAndSettle(); + expect(api.catalogCalls, [ + for (final page in [0, 1, 2]) + { + 'kind': preset.$2, + 'genre': preset.$3, + 'country': 0, + 'order': preset.$4, + 'page': page, + }, + ]); + } + }, + ); + } + + for (final entry in [ + ('Actors', ActorsPage), + + ('Editor picks', CuratedPage), + ('Best collections', CuratedPage), + ('Best by year', RecommendationsPage), + ]) { + testWidgets('Discover reaches ${entry.$1}', (tester) async { + await mountCatalog(tester, DiscoveryApi(), const DiscoverPage()); + await openDirectory(tester); + await tester.scrollUntilVisible(find.text(entry.$1), 300); + await tester.ensureVisible(find.text(entry.$1)); + await tester.pumpAndSettle(); + await tester.tap(find.text(entry.$1)); + await tester.pumpAndSettle(); + expect(find.byType(entry.$2), findsOneWidget); + if (entry.$1 == 'Best by year') { + expect( + tester + .widget(find.byType(RecommendationsPage)) + .byYear, + isTrue, + ); + } + }); + } +} diff --git a/test/catalog_external_player_test.dart b/test/catalog_external_player_test.dart new file mode 100644 index 0000000..c9f5530 --- /dev/null +++ b/test/catalog_external_player_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/media_platform.dart'; +import 'package:reelbox/ui/catalog.dart'; + +import 'catalog_widget_test.dart' show CatalogApi, mountCatalog; + +void main() { + testWidgets( + 'source has a visible external-player button independent of preference', + (tester) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + calls.add(call); + return call.method == 'openExternal'; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ); + }); + final state = await mountCatalog( + tester, + CatalogApi(), + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + addTearDown(state.dispose); + expect(state.prefs.externalPlayer, false); + final button = find.widgetWithText(TextButton, 'External player').first; + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + final call = calls.singleWhere((call) => call.method == 'openExternal'); + expect(call.arguments['url'], 'https://media.example/movie.mp4'); + expect(call.arguments['packageName'], 'ask'); + expect(state.prefs.externalPlayer, false); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'external-player failure is recoverable without opening an internal decoder', + (tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method == 'openExternal') { + throw PlatformException(code: 'NO_PLAYER'); + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ); + }); + final state = await mountCatalog( + tester, + CatalogApi(), + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + addTearDown(state.dispose); + final button = find.widgetWithText(TextButton, 'External player').first; + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pump(); + expect( + find.text('No external player could open this source.'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/test/catalog_identity_test.dart b/test/catalog_identity_test.dart new file mode 100644 index 0000000..3fcacda --- /dev/null +++ b/test/catalog_identity_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/ui/catalog.dart'; + +import 'catalog_widget_test.dart' show CatalogApi, mountCatalog; + +class CollisionApi extends CatalogApi { + int channelCalls = 0; + int posterCalls = 0; + + @override + Future channel(int id) async { + channelCalls++; + return TitleRecord(id: id, title: 'Channel fixture', type: 'channel'); + } + + @override + Future poster(int id) { + posterCalls++; + return super.poster(id); + } +} + +void main() { + testWidgets( + 'legacy channel detail is blocked without changing a same-ID saved movie', + (tester) async { + final api = CollisionApi(); + final state = await mountCatalog( + tester, + api, + const TitlePage( + title: TitleRecord(id: 7, title: 'Channel fixture', type: 'channel'), + ), + ); + addTearDown(state.dispose); + const movie = TitleRecord(id: 7, title: 'Movie fixture'); + await state.toggleSaved(movie); + await tester.pumpAndSettle(); + expect(find.text('This title is unavailable.'), findsOneWidget); + expect(find.text('Add to watchlist'), findsNothing); + expect(find.text('In watchlist'), findsNothing); + expect(api.channelCalls, 0); + expect(api.posterCalls, 0); + expect(state.saved.single.toJson(), movie.toJson()); + expect(state.hasSaved(7), isTrue); + expect(state.hasSaved(7, kind: 'channel'), isFalse); + expect(find.text('Try again'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }, + ); + + for (final series in [false, true]) { + testWidgets( + '${series ? 'series' : 'movie'} detail keeps allowed watchlist identity after a blocked channel mutation', + (tester) async { + final api = CollisionApi()..series = series; + final state = await mountCatalog( + tester, + api, + TitlePage( + title: TitleRecord( + id: 7, + title: 'Allowed fixture', + type: series ? 'serie' : 'movie', + ), + ), + ); + addTearDown(state.dispose); + await state.toggleSaved( + const TitleRecord(id: 7, title: 'Channel fixture', type: 'channel'), + ); + await tester.pumpAndSettle(); + expect(state.saved, isEmpty); + expect(find.text('Add to watchlist'), findsOneWidget); + await tester.ensureVisible(find.text('Add to watchlist')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add to watchlist')); + await tester.pumpAndSettle(); + expect(state.saved.single.id, 7); + expect(state.saved.single.isSeries, series); + expect(state.hasSaved(7), isTrue); + expect(state.hasSaved(7, kind: 'channel'), isFalse); + expect(find.text('In watchlist'), findsOneWidget); + await tester.ensureVisible(find.text('In watchlist')); + await tester.pumpAndSettle(); + await tester.tap(find.text('In watchlist')); + await tester.pumpAndSettle(); + expect(state.saved, isEmpty); + expect(find.text('Add to watchlist'), findsOneWidget); + expect(api.posterCalls, 1); + expect(api.channelCalls, 0); + await tester.pumpWidget(const SizedBox()); + }, + ); + } +} diff --git a/test/catalog_paging_regression_test.dart b/test/catalog_paging_regression_test.dart new file mode 100644 index 0000000..41c3599 --- /dev/null +++ b/test/catalog_paging_regression_test.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' show StubAdapter, jsonBody; + +Future mountPaging( + WidgetTester tester, + ApiService api, + Widget page, { + bool settle = true, +}) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: api, + storage: await SharedPreferences.getInstance(), + ); + addTearDown(state.dispose); + await state.initialize(discover: false); + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp(home: page), + ), + ); + if (settle) await tester.pumpAndSettle(); + return state; +} + +Map titleRow(int id, String kind) => { + 'id': id, + 'type': kind, + 'title': '$kind $id', +}; + +void main() { + testWidgets( + 'failed raw page retries the same cursor without losing prior titles', + (tester) async { + final pages = []; + var failed = false; + final adapter = StubAdapter((request) { + if (request.uri.path.contains('/all/')) return jsonBody([]); + final page = int.parse(request.uri.pathSegments[7]); + pages.add(page); + if (page == 1 && !failed) { + failed = true; + return jsonBody({}, status: 503); + } + return jsonBody(page < 2 ? [titleRow(30 + page, 'movie')] : []); + }); + await mountPaging( + tester, + ApiService(dio: Dio()..httpClientAdapter = adapter), + const BrowsePage(initialCountry: 9), + ); + // Short pages now prefetch automatically; a failure still retains its cursor. + expect(pages, [0, 1]); + expect(find.text('movie 30'), findsOneWidget); + await tester.ensureVisible(find.text('Try again')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(find.text('movie 30'), findsOneWidget); + expect(find.text('movie 31'), findsOneWidget); + expect(pages, [0, 1, 1, 2]); + }, + ); + + testWidgets('filter changes reject stale raw pages', (tester) async { + final delayed = Completer(); + addTearDown(() { + if (!delayed.isCompleted) delayed.complete(jsonBody([])); + }); + var pendingRequested = false; + final adapter = StubAdapter((request) { + final path = request.uri.path; + if (path.contains('/all/')) return jsonBody([]); + if (path.contains('/movie/')) { + if (path.contains('/created/1/')) { + pendingRequested = true; + return delayed.future; + } + return jsonBody([titleRow(40, 'movie')]); + } + return jsonBody( + path.contains('/created/0/') ? [titleRow(50, 'serie')] : [], + ); + }); + await mountPaging( + tester, + ApiService(dio: Dio()..httpClientAdapter = adapter), + const BrowsePage(), + settle: false, + ); + for (var frame = 0; frame < 24 && !pendingRequested; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(pendingRequested, true); + await tester.ensureVisible(find.text('Series')); + await tester.tap(find.text('Series')); + await tester.pumpAndSettle(); + delayed.complete(jsonBody([titleRow(41, 'movie')])); + await tester.pumpAndSettle(); + expect(find.text('serie 50'), findsOneWidget); + expect(find.text('movie 41'), findsNothing); + expect(find.text('movie 40'), findsNothing); + }); + + testWidgets('duplicate-only title pages do not truncate later results', ( + tester, + ) async { + final pages = []; + final adapter = StubAdapter((request) { + final query = request.uri.queryParameters; + if (['search-trand', 'best-rand-top'].contains(query['action'])) { + return jsonBody([]); + } + final page = int.parse(query['pageno']!); + pages.add(page); + return jsonBody(switch (page) { + 1 || 2 => [titleRow(20, 'movie')], + 3 => [titleRow(21, 'movie')], + _ => [], + }); + }); + await mountPaging( + tester, + ApiService(dio: Dio()..httpClientAdapter = adapter), + const SearchPage(), + ); + await tester.enterText(find.byType(TextField).first, 'movie'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(find.text('movie 20'), findsOneWidget); + expect(find.text('movie 21'), findsOneWidget); + expect(pages, [1, 2, 3, 4]); + expect(find.text('Load more'), findsNothing); + }); + + testWidgets( + 'duplicate-only actor directory pages retain server continuation', + (tester) async { + final pages = []; + final adapter = StubAdapter((request) { + final page = int.parse(request.uri.queryParameters['pageno']!); + pages.add(page); + return jsonBody(switch (page) { + 1 || 2 => [ + {'id': 1, 'name': 'Actor one'}, + ], + 3 => [ + {'id': 2, 'name': 'Actor two'}, + ], + _ => [], + }); + }); + await mountPaging( + tester, + ApiService(dio: Dio()..httpClientAdapter = adapter), + const ActorsPage(), + ); + expect(find.text('Actor one'), findsOneWidget); + expect(find.text('Actor two'), findsOneWidget); + expect(pages, [1, 2, 3, 4]); + }, + ); + + testWidgets('country movies retain continuation across opposite-kind pages', ( + tester, + ) async { + final pages = []; + final adapter = StubAdapter((request) { + if (request.uri.path.contains('/all/')) return jsonBody([]); + final page = int.parse(request.uri.pathSegments[7]); + pages.add(page); + return jsonBody(switch (page) { + 0 => [titleRow(10, 'serie')], + 1 => [titleRow(11, 'serie')], + 2 => [titleRow(12, 'movie')], + _ => [], + }); + }); + await mountPaging( + tester, + ApiService(dio: Dio()..httpClientAdapter = adapter), + const BrowsePage(initialCountry: 9), + ); + expect(find.text('No titles found'), findsNothing); + expect(find.text('movie 12'), findsOneWidget); + expect(find.text('serie 10'), findsNothing); + expect(pages, [0, 1, 2, 3]); + expect(find.text('Load more'), findsNothing); + expect(find.text('You’re all caught up'), findsOneWidget); + }); +} diff --git a/test/catalog_playback_preferences_test.dart b/test/catalog_playback_preferences_test.dart new file mode 100644 index 0000000..03546b4 --- /dev/null +++ b/test/catalog_playback_preferences_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/foundation.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_platform.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/ui/catalog.dart'; + +import 'catalog_widget_test.dart' show CatalogApi, mountCatalog; + +class QualityApi extends CatalogApi { + @override + Future poster(int id) async => TitleRecord( + id: id, + title: 'Quality fixture', + sources: const [ + MediaSource( + id: 1, + quality: '720p', + type: 'mp4', + url: 'https://media.example/720.mp4', + ), + MediaSource( + id: 2, + quality: '1080p', + type: 'mp4', + url: 'https://media.example/1080.mp4', + ), + ], + ); +} + +void main() { + testWidgets( + 'ask quality waits for an explicit source choice and supports cancel', + (tester) async { + final state = await mountCatalog( + tester, + QualityApi(), + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + expect(state.prefs.defaultQuality, 'ask'); + final play = find.byKey(const ValueKey('play-preferred')); + await tester.ensureVisible(play); + await tester.tap(play); + await tester.pumpAndSettle(); + expect(find.text('Choose a source'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Choose a source'), findsNothing); + expect(find.byType(TitlePage), findsOneWidget); + }, + ); + + testWidgets('preferred quality is used by the primary play action', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + calls.add((call.arguments as Map)['url'] as String); + return true; + }, + ); + final state = await mountCatalog( + tester, + QualityApi(), + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + await state.updateSettings( + state.prefs.copyWith(externalPlayer: true, defaultQuality: '1080'), + ); + await tester.pumpAndSettle(); + final play = find.byKey(const ValueKey('play-preferred')); + expect(play, findsOneWidget); + await tester.ensureVisible(play); + await tester.tap(play); + await tester.pumpAndSettle(); + expect(calls, ['https://media.example/1080.mp4']); + debugDefaultTargetPlatformOverride = null; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ); + }); + + testWidgets( + 'external-player preference dispatches selected media to native player', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method == 'openExternal') { + calls.add((call.arguments as Map)['url'] as String); + return true; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mountCatalog( + tester, + CatalogApi(), + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + await state.updateSettings(state.prefs.copyWith(externalPlayer: true)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byTooltip('Play').first); + await tester.tap(find.byTooltip('Play').first); + await tester.pumpAndSettle(); + expect(calls, ['https://media.example/movie.mp4']); + debugDefaultTargetPlatformOverride = null; + }, + ); +} diff --git a/test/catalog_widget_test.dart b/test/catalog_widget_test.dart new file mode 100644 index 0000000..04b5ff6 --- /dev/null +++ b/test/catalog_widget_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CatalogApi extends ApiService { + int homeCalls = 0; + bool failHome = false; + @override + Future> home() async { + homeCalls++; + if (failHome) throw Exception('Offline'); + return { + 'genres': [ + { + 'id': 2, + 'title': 'From the server', + 'posters': [const TitleRecord(id: 7, title: 'Real fixture').toJson()], + }, + ], + }; + } + + @override + Future initialize({bool discover = true}) async {} + bool series = false; + @override + Future poster(int id) async => TitleRecord( + id: id, + title: 'Fixture detail', + type: series ? 'serie' : 'movie', + description: 'Actual fixture synopsis', + genres: [const Tag(id: 2, title: 'Action')], + sources: [ + const MediaSource( + id: 1, + quality: '1080p', + type: 'mp4', + url: 'https://media.example/movie.mp4', + ), + const MediaSource( + id: 2, + quality: 'Trailer', + type: 'mp4', + url: 'https://media.example/trailer.mp4', + ), + ], + ); + @override + Future> seasons(int id) async => [ + const SeasonRecord( + id: 1, + title: 'Season 1', + episodes: [ + EpisodeRecord( + id: 51, + title: 'Episode 1', + sources: [ + MediaSource( + id: 1, + quality: '720p', + url: 'https://media.example/1.mp4', + ), + ], + ), + EpisodeRecord( + id: 52, + title: 'Episode 2', + sources: [ + MediaSource( + id: 2, + quality: '720p', + url: 'https://media.example/2.mp4', + ), + ], + ), + ], + ), + ]; + @override + Future>> roles(int posterId) async => []; + int commentCalls = 0; + @override + Future>> comments(int posterId) async { + commentCalls++; + return [{'id': 1, 'user': 'Viewer', 'content': 'SGVsbG8='}]; + } + @override + Future>> subtitles(String kind, int id) async => []; + final catalogCalls = >[]; + final searchCalls = >[]; + @override + Future> genres() async => [const Tag(id: 3, title: 'Animation')]; + @override + Future> countries() async => [const Tag(id: 1, title: 'Country')]; + @override + Future> categories() async => []; + @override + Future> catalog({ + String kind = 'movie', + int genre = 0, + int country = 0, + String order = 'created', + int page = 0, + }) async { + catalogCalls.add({ + 'kind': kind, + 'genre': genre, + 'country': country, + 'order': order, + 'page': page, + }); + return page < 2 ? [TitleRecord(id: page + 20, title: 'Page $page')] : []; + } + + @override + Future> trending() async => ['سرچ']; + @override + Future> search( + String query, { + int page = 1, + bool english = false, + }) async { + searchCalls.add({'q': query, 'page': page, 'english': english}); + return CatalogPage( + page == 1 ? [TitleRecord(id: 41, title: 'Result $query')] : [], + nextPage: null, + ); + } + + @override + Future> recommended(int genre, {int page = 1}) async => []; +} + +Future mountCatalog( + WidgetTester tester, + CatalogApi api, + Widget page, +) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: api, + storage: await SharedPreferences.getInstance(), + ); + await state.initialize(discover: false); + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp(theme: ThemeData.dark(useMaterial3: true), home: page), + ), + ); + await tester.pumpAndSettle(); + return state; +} + +void main() { + testWidgets( + 'movie detail exposes sources and local watchlist without public comments', + (tester) async { + final api = CatalogApi(); + final state = await mountCatalog( + tester, + api, + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + expect(find.text('Fixture detail'), findsWidgets); + await tester.ensureVisible(find.text('Add to watchlist')); + await tester.tap(find.text('Add to watchlist')); + await tester.pumpAndSettle(); + expect(state.hasSaved(7), isTrue); + await tester.ensureVisible(find.text('1080p')); + expect(find.text('1080p'), findsOneWidget); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -1500)); + await tester.pumpAndSettle(); + expect(find.text('Comments'), findsNothing); + expect(find.text('نظرات'), findsNothing); + expect(find.text('Replies'), findsNothing); + expect(api.commentCalls, 0); + expect(find.textContaining('Sign in'), findsNothing); + expect(find.byType(TextField), findsNothing); + }, + ); + testWidgets('series detail uses nested real seasons and episode sources', ( + tester, + ) async { + final api = CatalogApi()..series = true; + await mountCatalog( + tester, + api, + const TitlePage( + title: TitleRecord(id: 7, title: 'Fixture', type: 'serie'), + ), + ); + await tester.ensureVisible(find.text('Season 1')); + await tester.tap(find.text('Season 1')); + await tester.pumpAndSettle(); + expect(find.text('Episode 1'), findsOneWidget); + expect(find.text('Episode 2'), findsOneWidget); + await tester.ensureVisible(find.text('Episode 1')); + await tester.tap(find.text('Episode 1')); + await tester.pumpAndSettle(); + expect(find.text('720p'), findsOneWidget); + expect(find.byTooltip('Play'), findsWidgets); + }); + testWidgets( + 'browse anime preserves mixed genre 3 across automatically loaded pages', + (tester) async { + final api = CatalogApi(); + await mountCatalog( + tester, + api, + const BrowsePage(initialKind: 'mixed', initialGenre: 3), + ); + // Small real-result fixture pages are prefetched until the viewport is + // filled or the upstream empty page is reached, without changing filters. + expect(api.catalogCalls.map((call) => call['page']), [0, 1, 2]); + for (final call in api.catalogCalls) { + expect(call['kind'], 'mixed'); + expect(call['genre'], 3); + } + expect(find.text('Page 0'), findsOneWidget); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('You’re all caught up'), findsOneWidget); + }, + ); + testWidgets( + 'search submits Persian query and switches to English endpoint explicitly', + (tester) async { + final api = CatalogApi(); + await mountCatalog(tester, api, const SearchPage()); + await tester.scrollUntilVisible( + find.text('سرچ'), + 200, + scrollable: find.byType(Scrollable).last, + ); + expect(find.text('سرچ'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, 'فیلم'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(api.searchCalls.single, { + 'q': 'فیلم', + 'page': 1, + 'english': false, + }); + await tester.tap(find.text('Advanced filters')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('English search')); + await tester.tap(find.text('English search')); + await tester.pumpAndSettle(); + expect(api.searchCalls.last['english'], true); + expect(find.text('Result فیلم'), findsOneWidget); + }, + ); + testWidgets('Discover displays server rail without refetching on rebuild', ( + tester, + ) async { + final api = CatalogApi(); + final state = await mountCatalog(tester, api, const DiscoverPage()); + expect(find.text('From the server'), findsOneWidget); + expect(find.text('Real fixture'), findsWidgets); + expect(api.homeCalls, 1); + await state.toggleSaved(const TitleRecord(id: 12, title: 'Saved')); + await tester.pumpAndSettle(); + expect(api.homeCalls, 1); + }); +} diff --git a/test/comments_removed_test.dart b/test/comments_removed_test.dart new file mode 100644 index 0000000..b9a9fc0 --- /dev/null +++ b/test/comments_removed_test.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/content_policy.dart'; + +import 'api_service_test.dart' as fixtures; + +void main() { + test('public comments compatibility never sends a request', () async { + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + expect(await api.comments(7), isEmpty); + expect(adapter.requests, isEmpty); + }); + + test( + 'raw comment paths and reply actions are blocked before transport', + () async { + final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([])); + final api = ApiService(dio: Dio()..httpClientAdapter = adapter); + for (final path in [ + 'comments/by/poster/7/key/', + 'comment/add/', + '/api/%63omments/by/poster/7/key/', + '/api-user/newapi/comments.php', + '/api-user/filmahang/star.php?action=pasokh&cid=7', + '/api-user/newapi/comments_list.php', + '/api-user/newapi/star.php?action=comments&action=search', + ]) { + expect( + ContentPolicy.allowsApiPath(Uri.parse(path)), + isFalse, + reason: path, + ); + await expectLater(api.get(path), throwsA(isA())); + } + for (final action in ['comments', 'add-comment', 'pasokh', 'reply']) { + await expectLater( + api.get('/api-user/filmahang/star.php', query: {'action': action}), + throwsA(isA()), + ); + await expectLater( + api.post('/api-user/filmahang/star.php', {'action': action}), + throwsA(isA()), + ); + } + await expectLater( + api.get( + '/api-user/filmahang/star.php?action=comments', + query: {'action': 'search'}, + ), + throwsA(isA()), + ); + expect(adapter.requests, isEmpty); + }, + ); +} diff --git a/test/common_content_and_layout_test.dart b/test/common_content_and_layout_test.dart new file mode 100644 index 0000000..52f392e --- /dev/null +++ b/test/common_content_and_layout_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/common.dart'; + +Future stateForTest() async { + SharedPreferences.setMockInitialValues({}); + final state = AppState(api: ApiService()); + await state.initialize(discover: false); + return state; +} + +Widget host(AppState state, Widget child) => AppScope( + state: state, + child: MaterialApp(home: Scaffold(body: child)), +); + +void main() { + const movie = TitleRecord(id: 1, title: 'A real film'); + const promo = TitleRecord(id: 2, title: 'ملودی باکس'); + const station = TitleRecord( + id: 3, + title: 'Old live station', + type: 'channel', + ); + + testWidgets( + 'shared grids reject removed records even when API parsing is bypassed', + (tester) async { + final state = await stateForTest(); + await tester.pumpWidget( + host(state, const PosterGrid(items: [movie, promo, station])), + ); + expect(find.text('A real film'), findsOneWidget); + expect(find.text('ملودی باکس'), findsNothing); + expect(find.text('Old live station'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + state.dispose(); + }, + ); + testWidgets('a directly constructed removed poster never paints its label', ( + tester, + ) async { + final state = await stateForTest(); + await tester.pumpWidget( + host(state, const SizedBox(height: 280, child: PosterCard(title: promo))), + ); + expect(find.text('ملودی باکس'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + state.dispose(); + }); + testWidgets('a promotional rail heading cannot reintroduce vendor content', ( + tester, + ) async { + final state = await stateForTest(); + await tester.pumpWidget( + host(state, const PosterRail(title: 'نورتون', items: [movie])), + ); + expect(find.text('نورتون'), findsNothing); + expect(find.byType(PosterCard), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + state.dispose(); + }); + testWidgets( + 'compact preference renders usable rows rather than poster grids', + (tester) async { + final state = await stateForTest(); + await state.updateSettings(state.prefs.copyWith(compactCatalog: true)); + await tester.pumpWidget(host(state, const PosterGrid(items: [movie]))); + expect(find.byType(GridView), findsNothing); + expect( + find.byKey(const ValueKey('compact-title-row:poster:1')), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + state.dispose(); + }, + ); + testWidgets('status messages have an explicit accessible dismiss action', ( + tester, + ) async { + final state = await stateForTest(); + await tester.pumpWidget( + host( + state, + Builder( + builder: (context) => TextButton( + onPressed: () => toast(context, 'Saved locally'), + child: const Text('Show status'), + ), + ), + ), + ); + await tester.tap(find.text('Show status')); + await tester.pumpAndSettle(); + final closeTooltip = MaterialLocalizations.of( + tester.element(find.byType(SnackBar)), + ).closeButtonTooltip; + expect(find.byTooltip(closeTooltip), findsOneWidget); + await tester.tap(find.byTooltip(closeTooltip)); + await tester.pumpAndSettle(); + expect(find.text('Saved locally'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + state.dispose(); + }); +} diff --git a/test/content_policy_test.dart b/test/content_policy_test.dart new file mode 100644 index 0000000..9e96c04 --- /dev/null +++ b/test/content_policy_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/content_policy.dart'; + +void main() { + group('removed service promotions cannot reach the interface', () { + for (final value in [ + 'ملودی باکس', + 'ملودي باكس', + 'مِلودی‌ باکس', + 'MELODY BOX', + 'MelodyBox music', + 'نورتون', + 'نُورتون', + 'Norton VPN', + 'دانلود نسخه جدید برنامه', + 'آپدیت برنامه سیمبا', + 'Original app support: https://t.me/original_support', + 'خبرهای سرویس در تلگرام', + '@OriginalSupportAccount', + ]) { + test('blocks $value', () { + expect(ContentPolicy.allowsText(value), isFalse); + expect(ContentPolicy.sanitizeText(value), isEmpty); + }); + } + test('retains normal film metadata and does not mutate the input', () { + final original = { + 'id': 5, + 'title': 'The Newsroom', + 'description': 'A story about a newspaper', + 'updated_at': '2026-10-01', + 'sources': [ + {'url': 'https://media.example/film.mp4', 'quality': '1080p'}, + ], + }; + expect(ContentPolicy.allowsTitleJson(original), isTrue); + expect(ContentPolicy.sanitizeJson(original), original); + expect(original['updated_at'], '2026-10-01'); + }); + test('removes complete promotion records, not merely their labels', () { + final clean = ContentPolicy.sanitizeJson({ + 'posters': [ + {'id': 1, 'title': 'Ordinary film', 'type': 'movie'}, + {'id': 2, 'title': 'ملودی باکس', 'type': 'movie'}, + { + 'id': 3, + 'title': 'Install the latest app', + 'url': 'https://cdn.example/app.apk', + }, + {'id': 4, 'title': 'Live station', 'type': 'channel'}, + ], + }) as Map; + expect((clean['posters'] as List).map((e) => e['id']), [1]); + }); + test( + 'discards original support/news/update/list/channel home payloads', + () { + final clean = ContentPolicy.sanitizeJson({ + 'posters': [ + {'id': 1, 'title': 'Film'}, + ], + 'channels': [ + {'id': 4, 'title': 'Station'}, + ], + 'public_playlists': [ + {'id': 2, 'title': 'Remote list'}, + ], + 'news': {'title': 'Latest news'}, + 'support': 'https://example.com/support', + 'update': {'version': '99', 'url': 'https://example.com/new.apk'}, + 'telegram_channel': '@OriginalAccount', + }) as Map; + expect(clean.keys, ['posters']); + }, + ); + test('preserves local collections and episode playback playlists', () { + final value = { + 'collections': [ + {'name': 'Weekend films', 'titles': []}, + ], + 'episodes': [ + {'id': 3, 'title': 'Episode 3'}, + ], + }; + expect(ContentPolicy.sanitizeJson(value), value); + }); + test('rejects channel and promotional records', () { + expect( + ContentPolicy.allowsTitleJson({ + 'id': 4, + 'title': 'Station', + 'type': 'channel', + }), + isFalse, + ); + expect( + ContentPolicy.allowsTitleJson({ + 'id': 5, + 'title': 'Movie', + 'description': 'نورتون', + }), + isFalse, + ); + expect( + ContentPolicy.allowsTitleJson({ + 'id': 6, + 'title': 'Movie', + 'type': 'news', + }), + isFalse, + ); + }); + }); + group('outgoing destination policy', () { + for (final url in [ + 'https://t.me/original', + 'https://www.telegram.me/original', + 'tg://resolve?domain=original', + 'https://telegram.org/original', + 'https://cdn.example/release.apk?version=2', + 'https://cdn.example/release%2Eapk', + 'https://example.com/original-app/support', + 'https://www.norton.com/', + 'https://melodybox.example/', + 'javascript:alert(1)', + 'intent://example', + ]) { + test('rejects $url', () => expect(ContentPolicy.allowsUrl(url), isFalse)); + } + test('allows normal artwork, film and subtitle media URLs', () { + for (final url in [ + 'https://media.example/film.mp4', + 'http://media.example/video.m3u8', + 'https://images.example/p.jpg', + 'https://subtitles.example/fa.srt', + ]) { + expect(ContentPolicy.allowsUrl(url), isTrue, reason: url); + } + }); + test( + 'blocks removed API families without blocking catalog and episodes', + () { + for (final path in [ + '/api/channel/by/2/key/', + '/api-user/newapi/like.php?action=showlist', + '/api-user/newapi/like.php?action=show-list_fav&listid=5', + ]) { + expect(ContentPolicy.allowsApiPath(Uri.parse(path)), isFalse); + } + expect( + ContentPolicy.allowsApiPath(Uri.parse('/api/season/by/serie/2/key/')), + isTrue, + ); + expect( + ContentPolicy.allowsApiPath(Uri.parse('/api/movie/by/2/key/')), + isTrue, + ); + }, + ); + }); +} diff --git a/test/download_android_child_dispatch_test.dart b/test/download_android_child_dispatch_test.dart new file mode 100644 index 0000000..5e2c58f --- /dev/null +++ b/test/download_android_child_dispatch_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Storage implements PersistentStorage { + @override + Future initialize() async {} + @override + Future> retrieveAllTaskRecords() async => []; + @override + Future removeResumeData(String? id) async {} + @override + Future removePausedTask(String? id) async {} + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const native = MethodChannel('com.bbflight.background_downloader'); + const connectivity = MethodChannel('dev.fluttercommunity.plus/connectivity'); + const events = MethodChannel('dev.fluttercommunity.plus/connectivity_status'); + const codec = StandardMethodCodec(); + late FileDownloader downloader; + final oldPlatform = debugDefaultTargetPlatformOverride; + + setUpAll(() async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + messenger.setMockMethodCallHandler(connectivity, (_) async => ['wifi']); + messenger.setMockMethodCallHandler(events, (_) async => null); + downloader = FileDownloader(persistentStorage: _Storage()); + await downloader.ready; + }); + tearDownAll(() { + downloader.destroy(); + debugDefaultTargetPlatformOverride = oldPlatform; + messenger.setMockMethodCallHandler(native, null); + messenger.setMockMethodCallHandler(connectivity, null); + messenger.setMockMethodCallHandler(events, null); + }); + + Future enqueueChild(String id) async { + final reply = Completer(); + final parent = ParallelDownloadTask( + taskId: 'parent-$id', + url: 'https://example.test/video.mp4', + ); + final child = DownloadTask( + taskId: id, + url: parent.url, + group: FileDownloader.chunkGroup, + ); + // Exercise the real plugin background handler and its JSON worker. + // ignore: deprecated_member_use + messenger.handlePlatformMessage( + 'com.bbflight.background_downloader.background', + codec.encodeMethodCall( + MethodCall('enqueueChild', [ + jsonEncode(parent.toJson()), + jsonEncode(child.toJson()), + ]), + ), + reply.complete, + ); + return codec.decodeEnvelope( + (await reply.future.timeout(const Duration(seconds: 3)))!, + ); + } + + test('child acknowledgement waits for native scheduling', () async { + final dispatched = Completer(); + final scheduled = Completer(); + messenger.setMockMethodCallHandler(native, (call) async { + if (call.method == 'enqueue') { + dispatched.complete(); + return scheduled.future; + } + return true; + }); + var replied = false; + final reply = enqueueChild('pending-child').then((value) { + replied = true; + return value; + }); + await dispatched.future.timeout(const Duration(seconds: 3)); + await Future.delayed(Duration.zero); + expect(replied, isFalse); + scheduled.complete(true); + expect(await reply, isTrue); + }); + + test( + 'native child dispatch error returns failure without a detached error', + () async { + messenger.setMockMethodCallHandler(native, (call) async { + throw PlatformException( + code: 'scheduler_rejected', + message: 'Fixture rejection', + ); + }); + expect(await enqueueChild('throwing-child'), isFalse); + await Future.delayed(const Duration(milliseconds: 150)); + }, + ); + + test('child enqueue rejection is not acknowledged as success', () async { + var attempts = 0; + messenger.setMockMethodCallHandler(native, (call) async { + if (call.method == 'enqueue') attempts++; + return false; + }); + final accepted = await enqueueChild('rejected-child'); + // Drain the old detached 100ms enqueue before asserting/teardown too. + await Future.delayed(const Duration(milliseconds: 150)); + expect(attempts, 1); + expect(accepted, isFalse); + }); +} diff --git a/test/download_android_connectivity_test.dart b/test/download_android_connectivity_test.dart new file mode 100644 index 0000000..bfcc50f --- /dev/null +++ b/test/download_android_connectivity_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Storage implements PersistentStorage { + @override + Future initialize() async {} + @override + Future> retrieveAllTaskRecords() async => []; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('Android connectivity initialization error fails ready without detached error', () async { + final previous = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = previous); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const connectivity = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + const events = MethodChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + messenger.setMockMethodCallHandler( + connectivity, + (_) async => throw PlatformException(code: 'network_unavailable'), + ); + messenger.setMockMethodCallHandler(events, (_) async => null); + addTearDown(() { + messenger.setMockMethodCallHandler(connectivity, null); + messenger.setMockMethodCallHandler(events, null); + }); + final unhandled = []; + final done = Completer(); + Object? observed; + runZonedGuarded(() async { + final downloader = FileDownloader(persistentStorage: _Storage()); + try { + await downloader.ready; + } catch (error) { + observed = error; + } finally { + await Future.delayed(Duration.zero); + done.complete(); + } + }, (error, stack) => unhandled.add(error)); + await done.future; + expect( + observed, + isA().having( + (error) => error.code, + 'code', + 'network_unavailable', + ), + ); + expect(unhandled, isEmpty); + }); +} diff --git a/test/download_android_initialization_test.dart b/test/download_android_initialization_test.dart new file mode 100644 index 0000000..36f03ae --- /dev/null +++ b/test/download_android_initialization_test.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FailingStorage implements PersistentStorage { + final failure = StateError('download storage unavailable'); + @override + Future initialize() async => throw failure; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'Android initialization failure reaches ready instead of unhandled future', + () async { + final previous = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = previous); + final storage = _FailingStorage(); + final unhandled = []; + final done = Completer(); + Object? observed; + runZonedGuarded(() async { + final downloader = FileDownloader(persistentStorage: storage); + try { + await downloader.ready.timeout(const Duration(milliseconds: 200)); + } catch (error) { + observed = error; + } finally { + done.complete(); + } + }, (error, stack) => unhandled.add(error)); + await done.future; + expect(observed, same(storage.failure)); + expect(unhandled, isEmpty); + }, + ); +} diff --git a/test/download_android_permission_test.dart b/test/download_android_permission_test.dart new file mode 100644 index 0000000..f6ef92e --- /dev/null +++ b/test/download_android_permission_test.dart @@ -0,0 +1,60 @@ +// Exercise the pinned Android protocol without constructing connectivity/jobs. +// ignore: implementation_imports +import 'package:background_downloader/src/permissions.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.bbflight.background_downloader'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test( + 'failed permission dispatch releases slot for the next request', + () async { + final service = AndroidPermissionsService(); + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (++calls == 1) { + throw PlatformException( + code: 'activity_detached', + message: 'No activity', + ); + } + service.onPermissionRequestResult(PermissionStatus.granted); + return true; + }); + await expectLater( + service.request(PermissionType.notifications), + throwsA(isA()), + ); + expect( + await service.request(PermissionType.notifications), + PermissionStatus.granted, + ); + expect(calls, 2); + }, + ); + + test( + 'notification result preceding native request reply completes request', + () async { + final service = AndroidPermissionsService(); + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'requestPermission'); + expect(call.arguments, PermissionType.notifications.index); + // Android may deliver its activity callback before the method reply. + service.onPermissionRequestResult(PermissionStatus.denied); + return true; + }); + expect( + await service.request(PermissionType.notifications), + PermissionStatus.denied, + ); + expect(service.permissionStatusCompleter, isNull); + }, + ); +} diff --git a/test/download_android_persistence_test.dart b/test/download_android_persistence_test.dart new file mode 100644 index 0000000..a82db50 --- /dev/null +++ b/test/download_android_persistence_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Storage implements PersistentStorage { + final failure = FileSystemException('fixture storage is read-only'); + bool failWrites = true; + int writes = 0; + int? failWriteNumber; + final saved = {}; + @override + Future initialize() async {} + @override + Future retrieveTaskRecord(String id) async => saved[id]; + @override + Future> retrieveAllTaskRecords() async => + saved.values.toList(); + @override + Future storeTaskRecord(TaskRecord record) async { + writes++; + if (failWrites || writes == failWriteNumber) throw failure; + saved[record.taskId] = record; + } + + @override + Future removePausedTask(String? id) async {} + @override + Future removeResumeData(String? id) async {} + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'failed native status persistence fails flush and later writes still run', + () async { + final previous = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = previous); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const connectivity = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + const connectivityEvents = MethodChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + messenger.setMockMethodCallHandler(connectivity, (_) async => ['wifi']); + messenger.setMockMethodCallHandler(connectivityEvents, (_) async => null); + addTearDown(() { + messenger.setMockMethodCallHandler(connectivity, null); + messenger.setMockMethodCallHandler(connectivityEvents, null); + }); + final storage = _Storage(); + final unhandled = []; + final done = Completer(); + Object? observed; + Object? batchFailure; + var retried = false; + runZonedGuarded(() async { + final downloader = FileDownloader(persistentStorage: storage); + await downloader.ready; + final engine = downloader.downloaderForTesting; + engine.trackedGroups.add('fixture'); + engine.groupStatusCallbacks['fixture'] = (_) {}; + final task = DownloadTask( + taskId: 'read-only-status', + url: 'https://example.test/fixture.mp4', + group: 'fixture', + ); + try { + engine.processStatusUpdate( + TaskStatusUpdate(task, TaskStatus.running), + ); + try { + await engine.flushDatabaseUpdates(); + } catch (error) { + observed = error; + } + storage.failWrites = false; + engine.processStatusUpdate( + TaskStatusUpdate(task, TaskStatus.running), + ); + await engine.flushDatabaseUpdates(); + retried = storage.saved[task.taskId]?.status == TaskStatus.running; + // One background message can queue status plus progress. A later + // successful write must not turn the whole flush into false success. + storage.failWriteNumber = storage.writes + 1; + engine.processStatusUpdate( + TaskStatusUpdate(task, TaskStatus.enqueued), + ); + engine.processStatusUpdate( + TaskStatusUpdate(task, TaskStatus.running), + ); + try { + await engine.flushDatabaseUpdates(); + } catch (error) { + batchFailure = error; + } + await Future.delayed(Duration.zero); + } finally { + downloader.destroy(); + done.complete(); + } + }, (error, stack) => unhandled.add(error)); + await done.future; + expect(observed, same(storage.failure)); + expect(batchFailure, same(storage.failure)); + expect(unhandled, isEmpty); + expect(retried, isTrue); + }, + ); +} diff --git a/test/download_capabilities_test.dart b/test/download_capabilities_test.dart new file mode 100644 index 0000000..e0fb5f4 --- /dev/null +++ b/test/download_capabilities_test.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_files_io.dart' as files; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'parallel optimization requires a successful HEAD with size and ranges', + () async { + final previous = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = previous); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final methods = []; + server.listen((request) async { + methods.add(request.method); + switch (request.uri.path) { + case '/capable': + request.response.contentLength = 524288; + request.response.headers.set('accept-ranges', 'bytes'); + case '/no-head': + request.response.statusCode = HttpStatus.methodNotAllowed; + case '/no-ranges': + request.response.contentLength = 524288; + case '/unknown-size': + request.response.headers.set('accept-ranges', 'bytes'); + case '/forbidden': + request.response.statusCode = HttpStatus.forbidden; + default: + request.response.statusCode = HttpStatus.notFound; + } + await request.response.close(); + }); + String url(String path) => 'http://127.0.0.1:${server.port}$path'; + expect(await files.supportsParallelDownload(url('/capable')), isTrue); + for (final path in [ + '/no-head', + '/no-ranges', + '/unknown-size', + '/forbidden', + ]) { + expect( + await files.supportsParallelDownload(url(path)), + isFalse, + reason: path, + ); + } + expect(methods, List.filled(5, 'HEAD')); + expect( + await files.supportsParallelDownload('file:///private.mp4'), + isFalse, + ); + }, + ); +} diff --git a/test/download_compatibility_test.dart b/test/download_compatibility_test.dart new file mode 100644 index 0000000..d2a35b3 --- /dev/null +++ b/test/download_compatibility_test.dart @@ -0,0 +1,61 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/state/app_state.dart'; + +void main() { + test( + 'retry can replace an incompatible multipart task with a normal GET', + () { + final old = createDirectDownloadTask( + id: 'old-multipart', + title: const TitleRecord( + id: 81, + title: 'A local test film', + type: 'serie', + ), + source: const MediaSource( + url: 'https://example.test/video.mp4', + quality: '720p', + ), + episode: const EpisodeRecord(id: 9, title: 'Episode 1'), + preferences: AppSettings( + wifiOnlyDownloads: true, + downloadNotifications: false, + ), + supportsParallel: true, + ); + final entry = DownloadEntry.fromTask(old, taskStatus: TaskStatus.failed); + final retry = createRetryDownloadTask(entry, id: 'retry-single'); + expect(retry, isNot(isA())); + expect(retry.taskId, 'retry-single'); + expect(retry.url, old.url); + expect(retry.metaData, old.metaData); + expect(retry.requiresWiFi, isTrue); + expect(retry.notificationConfig, isNull); + expect(retry.retriesRemaining, old.retries); + expect(retry.filename, isNot(old.filename)); + expect(DownloadEntry.fromTask(retry).episodeId, 9); + expect(DownloadEntry.fromTask(retry).titleKind, 'serie'); + }, + ); + + test('default download works without server HEAD or byte-range support', () { + final task = createDirectDownloadTask( + id: 'ordinary-get', + title: const TitleRecord(id: 81, title: 'A local test film'), + source: const MediaSource( + url: 'https://example.test/video.mp4', + type: 'mp4', + ), + preferences: AppSettings(), + ); + // Multipart workers require both HEAD and range headers. A normal GET + // download must be the safe default, not a server-dependent optimization. + expect(task, isNot(isA())); + expect(task.httpRequestMethod, 'GET'); + expect(task.requiresWiFi, isFalse); + expect(task.updates, Updates.statusAndProgress); + }); +} diff --git a/test/download_identity_recovery_test.dart b/test/download_identity_recovery_test.dart new file mode 100644 index 0000000..002a017 --- /dev/null +++ b/test/download_identity_recovery_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/state/app_state.dart'; + +void main() { + for (final kind in ['channel', 'movie', 'serie', 'series']) { + test( + 'offline and retry reconstruction preserve $kind after disk round trip', + () { + final title = TitleRecord(id: 42, title: 'Fixture', type: kind); + final original = DownloadTask( + url: 'https://example.test/a.mp4', + metaData: downloadMetadata( + title: title, + source: const MediaSource(url: 'https://example.test/a.mp4'), + notifications: false, + ), + ); + final recovered = DownloadTask.fromJson( + jsonDecode(jsonEncode(original.toJson())), + ); + final restored = DownloadEntry.fromTask(recovered).restoredTitle; + expect(restored.type, kind); + expect(restored.libraryKey, title.libraryKey); + }, + ); + } + test( + 'legacy records retain the movie/episode fallback without guessing channel', + () { + for (final episodeId in [null, 9]) { + final task = DownloadTask( + url: 'https://example.test/a.mp4', + metaData: jsonEncode({'titleId': 42, 'episodeId': episodeId}), + ); + final title = DownloadEntry.fromTask(task).restoredTitle; + expect(title.type, episodeId == null ? 'movie' : 'serie'); + expect(title.libraryKey, 'poster:42'); + } + }, + ); +} diff --git a/test/download_multipart_http_test.dart b/test/download_multipart_http_test.dart new file mode 100644 index 0000000..e56c25e --- /dev/null +++ b/test/download_multipart_http_test.dart @@ -0,0 +1,525 @@ +// Console output is intentional evidence from this real-worker test fixture. +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +// flutter_tester does not attach the generated Dart plugin registrant in +// Flutter 3.47.9. Include the engine lookup constant and host-only registrar. +// Run under the shared compiler lock, with test concurrency=1: +// flock .tooling.lock flutter test --concurrency=1 \ +// --dart-define=flutter.dart_plugin_registrant=file://$PWD/test/download_multipart_http_test.dart \ +// test/download_multipart_http_test.dart +// ignore: implementation_imports, unused_import +import 'package:flutter/src/dart_plugin_registrant.dart'; +// The installed Linux path provider bootstraps real worker isolates only. +// ignore: depend_on_referenced_packages +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/state/app_state.dart'; + +// The engine invokes this in every real downloader isolate. Do not register +// connectivity_plus's DBus backend: the headless test host has no NetworkManager. +@pragma('vm:entry-point') +class _PluginRegistrant { + @pragma('vm:entry-point') + static void register() => PathProviderLinux.registerWith(); +} + +// Only host persistence/connectivity are fixtures. HTTP, range splitting, +// worker isolates, pause/resume, callbacks and stitching are real plugin code. +class _Storage implements PersistentStorage { + final records = {}, + paused = {}, + resumes = {}; + @override + (String, int) get currentDatabaseVersion => ('fixture', 1); + @override + Future<(String, int)> get storedDatabaseVersion async => + currentDatabaseVersion; + @override + Future initialize() async {} + @override + Future storeTaskRecord(TaskRecord r) async { + records[r.taskId] = r; + } + + @override + Future retrieveTaskRecord(String id) async => records[id]; + @override + Future> retrieveAllTaskRecords() async => + records.values.toList(); + @override + Future removeTaskRecord(String? id) async { + id == null ? records.clear() : records.remove(id); + } + + @override + Future storePausedTask(Task t) async { + paused[t.taskId] = t; + } + + @override + Future retrievePausedTask(String id) async => paused[id]; + @override + Future> retrieveAllPausedTasks() async => paused.values.toList(); + @override + Future removePausedTask(String? id) async { + id == null ? paused.clear() : paused.remove(id); + } + + @override + Future storeResumeData(ResumeData r) async { + resumes[r.taskId] = r; + } + + @override + Future retrieveResumeData(String id) async => resumes[id]; + @override + Future> retrieveAllResumeData() async => + resumes.values.toList(); + @override + Future removeResumeData(String? id) async { + id == null ? resumes.clear() : resumes.remove(id); + } + + // Reconstitute fresh task/resume objects from actual disk JSON, not merely + // another reference to the same objects. This is not a process restart test. + Future reloadResumeSnapshot(File file) async { + await file.writeAsString( + jsonEncode({ + 'paused': paused.values.map((t) => t.toJson()).toList(), + 'resumes': resumes.values.map((r) => r.toJson()).toList(), + }), + flush: true, + ); + paused.clear(); + resumes.clear(); + final json = jsonDecode(await file.readAsString()) as Map; + for (final value in json['paused'] as List) { + final task = Task.createFromJson(value as Map); + paused[task.taskId] = task; + } + for (final value in json['resumes'] as List) { + final resume = ResumeData.fromJson(value as Map); + resumes[resume.taskId] = resume; + } + } +} + +Future _until(FutureOr Function() predicate, String reason) async { + final watch = Stopwatch()..start(); + while (!await predicate()) { + if (watch.elapsed > const Duration(seconds: 10)) { + fail('Timed out waiting for $reason'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + +class _RangeFixture { + static const chunkLength = 262144; + final bytes = Uint8List.fromList( + List.generate(chunkLength * 3, (i) => i % 251), + ); + final ranges = <(int, int)>[]; + final errors = []; + final handlers = >[]; + final bool throttle; + late final HttpServer server; + late final Directory root; + var active = 0, peak = 0; + var closing = false; + final shutdown = Completer(); + + _RangeFixture({this.throttle = false}); + + Future start() async { + root = await Directory.systemTemp.createTemp('reelbox-multipart-'); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) => handlers.add(_serve(request))); + } + + Future _serve(HttpRequest request) async { + final response = request.response; + response.headers.set('accept-ranges', 'bytes'); + response.headers.set('content-type', 'video/mp4'); + response.headers.set('etag', '"reelbox-fixture-v1"'); + if (request.method == 'HEAD') { + response.contentLength = bytes.length; + await response.close(); + return; + } + final match = RegExp(r'^bytes=(\d+)-(\d*)$') + .firstMatch(request.headers.value('range') ?? ''); + if (match == null) { + errors.add(StateError('Expected an actual range request')); + response.statusCode = 400; + await response.close(); + return; + } + final start = int.parse(match[1]!); + final end = match[2]!.isEmpty ? bytes.length - 1 : int.parse(match[2]!); + ranges.add((start, end)); + response.statusCode = 206; + response.headers.set('content-range', 'bytes $start-$end/${bytes.length}'); + response.contentLength = end - start + 1; + active++; + if (active > peak) peak = active; + try { + // Range zero finishes first; two other workers genuinely transfer bytes + // slowly so the parent can pause only after the fast worker completed. + await Future.delayed(const Duration(milliseconds: 50)); + final step = throttle && start >= chunkLength ? 4096 : end - start + 1; + for (var offset = start; offset <= end && !closing; offset += step) { + final next = offset + step > end + 1 ? end + 1 : offset + step; + response.add(bytes.sublist(offset, next)); + // A paused worker stops consuming the old response. On teardown, + // cancel that server-side wait too; force-close alone can strand flush. + await Future.any([response.flush(), shutdown.future]); + if (step == 4096) { + await Future.delayed(const Duration(milliseconds: 40)); + } + } + if (!closing) await Future.any([response.close(), shutdown.future]); + } on SocketException catch (error) { + // Pause closes the real worker's connection partway through a response. + if (!throttle && !closing) errors.add(error); + } on HttpException catch (error) { + if (!throttle && !closing) errors.add(error); + } finally { + active--; + } + } + + DownloadTask task(String id) => createDirectDownloadTask( + id: id, + title: const TitleRecord(id: 8, title: 'Fixture'), + source: MediaSource(url: 'http://127.0.0.1:${server.port}/video.mp4'), + supportsParallel: true, + preferences: AppSettings( + parallelDownloads: 3, + downloadNotifications: false, + ), + ).copyWith(baseDirectory: BaseDirectory.root, directory: root.path); + + Future verify(DownloadTask task) async { + final actual = await File(await task.filePath()).readAsBytes(); + expect(actual.length, bytes.length); + expect(actual, orderedEquals(bytes)); + expect(sha256.convert(actual), sha256.convert(bytes)); + expect(errors, isEmpty); + expect(peak, greaterThan(1)); + print( + '[multipart-result] ${task.taskId}: bytes=${actual.length} ' + 'sha256=${sha256.convert(actual)} ranges=$ranges peak=$peak', + ); + } + + Future close() async { + closing = true; + shutdown.complete(); + await server.close(force: true); + await Future.wait(handlers); + await root.delete(recursive: true); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final storage = _Storage(); + late FileDownloader downloader; + final previousPlatform = debugDefaultTargetPlatformOverride; + final previousHttp = HttpOverrides.current; + + setUpAll(() async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + HttpOverrides.global = null; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async => ['wifi'], + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + (call) async => null, + ); + downloader = FileDownloader(persistentStorage: storage); + await downloader.ready; + }); + tearDownAll(() { + downloader.destroy(); + debugDefaultTargetPlatformOverride = previousPlatform; + HttpOverrides.global = previousHttp; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + null, + ); + }); + setUp(() { + Logger.root.level = Level.ALL; + final logs = Logger.root.onRecord.listen((record) { + print( + '[multipart] ${record.loggerName} ${record.level}: ${record.message}', + ); + if (record.error != null) print(record.error); + if (record.stackTrace != null) print(record.stackTrace); + }); + addTearDown(logs.cancel); + }); + + test( + 'uninterrupted real three-range transfer stitches exact bytes and SHA-256', + () async { + final fixture = _RangeFixture(); + await fixture.start(); + addTearDown(fixture.close); + await downloader.configure( + desktopConfig: (Config.tempFilePath, fixture.root.path), + ); + final task = fixture.task('multipart-uninterrupted'); + expect(task, isA()); + final result = await downloader + .download(task, onProgress: (_) {}) + .timeout(const Duration(seconds: 20)); + expect( + result.status, + TaskStatus.complete, + reason: result.exception?.description, + ); + await fixture.verify(task); + expect(fixture.ranges.toSet(), { + (0, _RangeFixture.chunkLength - 1), + (_RangeFixture.chunkLength, _RangeFixture.chunkLength * 2 - 1), + (_RangeFixture.chunkLength * 2, fixture.bytes.length - 1), + }); + expect(fixture.ranges, hasLength(3)); + }, + ); + + test('default download transfers exact bytes from a GET-only server', () async { + final root = await Directory.systemTemp.createTemp('reelbox-get-only-'); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final payload = Uint8List.fromList(List.generate(262144, (i) => i % 239)); + final methods = []; + final ranges = []; + server.listen((request) async { + methods.add(request.method); + ranges.add(request.headers.value('range')); + if (request.method != 'GET' || request.headers.value('range') != null) { + request.response.statusCode = HttpStatus.methodNotAllowed; + } else { + request.response.headers.contentType = ContentType('video', 'mp4'); + request.response.contentLength = payload.length; + request.response.add(payload); + } + await request.response.close(); + }); + addTearDown(() async { + await server.close(force: true); + await root.delete(recursive: true); + }); + await downloader.configure(desktopConfig: (Config.tempFilePath, root.path)); + final task = + createDirectDownloadTask( + id: 'get-only-download', + title: const TitleRecord(id: 81, title: 'A local test film'), + source: MediaSource(url: 'http://127.0.0.1:${server.port}/video.mp4'), + preferences: AppSettings(downloadNotifications: false), + ).copyWith( + baseDirectory: BaseDirectory.root, + directory: root.path, + retries: 0, + ); + final result = await downloader + .download(task, onProgress: (_) {}) + .timeout(const Duration(seconds: 20)); + expect( + result.status, + TaskStatus.complete, + reason: result.exception?.description, + ); + final actual = await File(await task.filePath()).readAsBytes(); + expect(actual, orderedEquals(payload)); + expect(sha256.convert(actual), sha256.convert(payload)); + expect(methods, ['GET']); + expect(ranges, [null]); + print( + '[get-only-result] bytes=${actual.length} sha256=${sha256.convert(actual)}', + ); + }); + + for (final reload in [false, true]) { + test( + 'resume after fast chunk completed preserves bytes/hash${reload ? ' with disk JSON rehydration' : ''}', + () async { + final fixture = _RangeFixture(throttle: true); + await fixture.start(); + addTearDown(fixture.close); + await downloader.configure( + desktopConfig: (Config.tempFilePath, fixture.root.path), + ); + final task = fixture.task( + reload ? 'multipart-rehydrated' : 'multipart-resume', + ); + final statuses = []; + final complete = Completer(); + downloader.registerCallbacks( + group: task.group, + taskProgressCallback: (_) {}, + taskStatusCallback: (update) { + if (update.task.taskId != task.taskId) return; + statuses.add(update.status); + if (update.status.isFinalState && !complete.isCompleted) { + complete.complete(update); + } + }, + ); + final chunks = []; + addTearDown(() async { + await downloader.cancelTasksWithIds([ + task.taskId, + ...chunks.map((t) => t.taskId), + ]); + await _until( + () async => + (await downloader.allTasks(allGroups: true)) + .where((t) => t.taskId == task.taskId || chunks.contains(t)) + .isEmpty, + 'worker cleanup', + ); + downloader.unregisterCallbacks(group: task.group); + for (final chunk in chunks) { + final file = File(await chunk.filePath()); + if (await file.exists()) await file.delete(); + } + storage.paused.clear(); + storage.resumes.clear(); + }); + expect(await downloader.enqueue(task), isTrue); + await _until(() async { + chunks.clear(); + chunks.addAll( + await downloader.allTasks(group: FileDownloader.chunkGroup), + ); + return chunks.length == 3; + }, 'three real chunk workers'); + final fast = chunks.singleWhere( + (t) => + t.headers['Range'] == 'bytes=0-${_RangeFixture.chunkLength - 1}', + ); + await _until( + () async => + await File(await fast.filePath()).exists() && + !(await downloader.allTasks(group: FileDownloader.chunkGroup)) + .contains(fast), + 'fast chunk file and worker completion', + ); + await _until(() async { + var partials = 0; + await for (final entry in fixture.root.list()) { + if (entry is File && + entry.path.split('/').last.startsWith('com.bbflight.')) { + final length = await entry.length(); + if (length > 0 && length < _RangeFixture.chunkLength) partials++; + } + } + return partials == 2; + }, 'both slow chunks to have actually written partial bytes'); + expect(await downloader.pause(task), isTrue); + await _until( + () => + statuses.contains(TaskStatus.paused) && + storage.resumes.containsKey(task.taskId) && + chunks + .where((t) => t != fast) + .every((t) => storage.resumes.containsKey(t.taskId)), + 'parent and unfinished chunk resume records', + ); + // The completed status is produced by the actual parent worker, not injected. + final savedChunks = + jsonDecode(storage.resumes[task.taskId]!.data) as List; + final savedFast = savedChunks.cast>().singleWhere( + (c) => c['fromByte'] == 0, + ); + expect(savedFast['status'], TaskStatus.complete.index); + expect(storage.resumes.containsKey(fast.taskId), isFalse); + expect( + await File(await fast.filePath()).readAsBytes(), + orderedEquals(fixture.bytes.sublist(0, _RangeFixture.chunkLength)), + ); + final unfinished = chunks.where((t) => t != fast).toList(); + final expectedResumedRanges = unfinished.map((t) { + final resume = storage.resumes[t.taskId]!; + expect(resume.requiredStartByte, greaterThan(0)); + expect(resume.eTag, '"reelbox-fixture-v1"'); + final original = RegExp(r'bytes=(\d+)-(\d+)') + .firstMatch(t.headers['Range']!)!; + return ( + int.parse(original[1]!) + resume.requiredStartByte, + int.parse(original[2]!), + ); + }).toSet(); + expect(fixture.ranges, hasLength(3)); + print( + '[multipart-paused] ${task.taskId}: completed=1 unfinished=2 ' + 'completedResumeRecord=false resumeRanges=$expectedResumedRanges', + ); + var resumeTask = task; + if (reload) { + await storage.reloadResumeSnapshot( + File('${fixture.root.path}/resume.json'), + ); + resumeTask = storage.paused[task.taskId]! as DownloadTask; + expect(identical(resumeTask, task), isFalse); + } + expect( + await downloader.resume(resumeTask), + isTrue, + reason: + 'Completed chunks have no resume data and must not be resumed', + ); + final result = await complete.future.timeout( + const Duration(seconds: 20), + ); + expect( + result.status, + TaskStatus.complete, + reason: result.exception?.description, + ); + expect( + statuses, + containsAllInOrder([ + TaskStatus.running, + TaskStatus.paused, + TaskStatus.running, + TaskStatus.complete, + ]), + ); + await fixture.verify(task); + expect(fixture.ranges, hasLength(5)); + expect(fixture.ranges.skip(3).toSet(), expectedResumedRanges); + expect( + fixture.ranges.where((r) => r.$1 == 0), + hasLength(1), + reason: 'The completed chunk must be reused, not downloaded again', + ); + }, + ); + } +} diff --git a/test/download_notifications_test.dart b/test/download_notifications_test.dart new file mode 100644 index 0000000..c30052e --- /dev/null +++ b/test/download_notifications_test.dart @@ -0,0 +1,25 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/download_service.dart'; + +void main() { + test('new downloads have no stale notification templates after opt out', () { + final service = DownloadService.instance; + service.configureNotifications(true); + final enabledTask = DownloadTask( + url: 'https://example.test/a.mp4', + group: DownloadService.group, + notificationConfig: service.notificationConfigForNewTask, + ); + expect(enabledTask.notificationConfig?.complete, isNotNull); + service.configureNotifications(false); + final disabledTask = DownloadTask( + url: 'https://example.test/b.mp4', + group: DownloadService.group, + notificationConfig: service.notificationConfigForNewTask, + ); + expect(disabledTask.notificationConfig, isNull); + // Changing the preference does not lie about altering an active OS job. + expect(enabledTask.notificationConfig?.running, isNotNull); + }); +} diff --git a/test/download_queue_next_test.dart b/test/download_queue_next_test.dart new file mode 100644 index 0000000..85c57e2 --- /dev/null +++ b/test/download_queue_next_test.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/state/app_state.dart'; + +void main() { + test('direct task uses pinned real multipart API and persists current source identity', () { + final task = createDirectDownloadTask( + id: 'parallel', + title: const TitleRecord(id: 6, title: 'Fixture', type: 'serie'), + source: const MediaSource( + url: 'https://example.test/video.mp4', + quality: '720p', + type: 'mp4', + ), + episode: const EpisodeRecord(id: 9, title: 'Episode'), + supportsParallel: true, + preferences: AppSettings( + parallelDownloads: 4, + downloadNotifications: false, + ), + ); + expect(task, isA()); + expect((task as ParallelDownloadTask).chunks, 4); + expect(task.allowPause, isTrue); + final metadata = jsonDecode(task.metaData) as Map; + expect(metadata['sourceType'], 'mp4'); + expect(metadata['episodeId'], 9); + expect(metadata['titleKind'], 'serie'); + expect(metadata['downloadNotifications'], false); + expect(Task.createFromJson(task.toJson()), isA()); + }); + test('excluded legacy tasks are canceled without deleting completed files or records', () async { + final task = DownloadTask( + taskId: 'legacy', + url: 'https://example.test/a.mp4', + metaData: jsonEncode({ + 'titleId': 7, + 'title': 'Melody Box', + 'titleKind': 'movie', + 'downloadNotifications': true, + }), + ); + final canceled = []; + await retireExcludedDownload( + TaskRecord(task, TaskStatus.running, 0, 0), + cancel: (id) async { + canceled.add(id); + return true; + }, + ); + expect(canceled, ['legacy']); + canceled.clear(); + await retireExcludedDownload( + TaskRecord(task, TaskStatus.complete, 1, 100), + cancel: (id) async { + canceled.add(id); + return true; + }, + ); + expect(canceled, isEmpty); + expect( + DownloadEntry.fromTask(task, taskStatus: TaskStatus.complete).visible, + false, + ); + }); + test( + 'retry retains old failed record until replacement enqueue succeeds', + () async { + final old = DownloadTask( + taskId: 'old', + url: 'https://example.test/video.mp4', + ); + final next = old.copyWith(taskId: 'new', filename: 'new.mp4'); + final events = []; + await expectLater( + replaceFailedDownload( + old, + next, + enqueue: (task) async { + events.add('enqueue'); + return false; + }, + deleteRecord: (id) async { + events.add('delete:$id'); + }, + ), + throwsStateError, + ); + expect(events, ['enqueue']); + events.clear(); + await replaceFailedDownload( + old, + next, + enqueue: (task) async { + events.add('enqueue'); + return true; + }, + deleteRecord: (id) async { + events.add('delete:$id'); + }, + ); + expect(events, ['enqueue', 'delete:old']); + }, + ); +} diff --git a/test/download_recovery_policy_test.dart b/test/download_recovery_policy_test.dart new file mode 100644 index 0000000..5c2b4c6 --- /dev/null +++ b/test/download_recovery_policy_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +// Exercise the pinned plugin's resolver without native/network initialization. +// ignore: implementation_imports +import 'package:background_downloader/src/base_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_service.dart'; + +void main() { + for (final enabled in [true, false]) { + test( + 'restart resume preserves original notifications=$enabled before dispatch', + () async { + final service = DownloadService.instance; + service.configureNotifications(enabled); + final original = DownloadTask( + url: 'https://example.test/a.mp4', + group: DownloadService.group, + notificationConfig: service.notificationConfigForNewTask, + metaData: downloadMetadata( + title: const TitleRecord(id: 7, title: 'Fixture'), + source: const MediaSource(url: 'https://example.test/a.mp4'), + notifications: enabled, + ), + ); + final recovered = DownloadTask.fromJson( + jsonDecode(jsonEncode(original.toJson())), + ); + expect(recovered.notificationConfig, isNull); + service.configureNotifications(!enabled); + final events = []; + final registry = {}; + final ok = await resumeDownloadTask( + DownloadEntry.fromTask(recovered), + configure: (task, config) { + events.add('configure'); + registry.add( + TaskNotificationConfig( + taskOrGroup: task, + running: config.running, + complete: config.complete, + error: config.error, + paused: config.paused, + progressBar: config.progressBar, + tapOpensFile: config.tapOpensFile, + ), + ); + }, + resume: (task) async { + events.add('resume'); + final registered = + BaseDownloader.notificationConfigForTaskUsingConfigSet( + task, + registry, + ); + expect(registered?.running != null, enabled); + expect(registered?.complete != null, enabled); + expect(registered?.error != null, enabled); + expect(registered?.paused != null, enabled); + return true; + }, + ); + expect(ok, isTrue); + expect(events, enabled ? ['configure', 'resume'] : ['resume']); + expect( + jsonDecode(recovered.metaData)['downloadNotifications'], + enabled, + ); + }, + ); + } +} diff --git a/test/download_refresh_test.dart b/test/download_refresh_test.dart new file mode 100644 index 0000000..5b4eb30 --- /dev/null +++ b/test/download_refresh_test.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/download_service.dart'; + +void main() { + test( + 'download refresh awaits durable records rather than repainting old state', + () async { + final pending = Completer>(); + var calls = 0; + var finished = false; + final result = readDownloadEntries(() { + calls++; + return pending.future; + }); + result.then((_) => finished = true); + await Future.delayed(Duration.zero); + expect(calls, 1); + expect(finished, isFalse); + final task = DownloadTask( + taskId: 'reloaded', + url: 'https://example.test/a.mp4', + baseDirectory: BaseDirectory.root, + directory: '/reelbox-offline', + filename: 'a.mp4', + ); + pending.complete([TaskRecord(task, TaskStatus.paused, .4, 1000)]); + final entries = await result; + expect(entries.single.id, 'reloaded'); + expect(entries.single.receivedBytes, 400); + expect(entries.single.canResume, isTrue); + expect(entries.single.path, '/reelbox-offline/a.mp4'); + }, + ); +} diff --git a/test/download_row_ui_test.dart b/test/download_row_ui_test.dart new file mode 100644 index 0000000..bd4b13e --- /dev/null +++ b/test/download_row_ui_test.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/media/downloads_page.dart'; + +import 'package:reelbox/state/app_state.dart'; + +import 'catalog_widget_test.dart' show CatalogApi, mountCatalog; + +Future mountRow(WidgetTester tester, Widget row) async { + final state = await mountCatalog(tester, CatalogApi(), const Scaffold()); + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp(home: Scaffold(body: row)), + ), + ); + await tester.pump(); + return state; +} + +void main() { + testWidgets('secondary external downloaders live behind the quiet overflow', ( + tester, + ) async { + final selected = []; + final state = await mountRow( + tester, + ExternalDownloadMenu(onSelected: selected.add), + ); + addTearDown(state.dispose); + expect(find.byIcon(Icons.more_vert), findsOneWidget); + expect(find.byIcon(Icons.download_for_offline_outlined), findsNothing); + expect(find.text('ADM'), findsNothing); + expect(selected, isEmpty); + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + expect(find.text('External downloader: ADM'), findsOneWidget); + expect(find.text('External downloader: IDM'), findsOneWidget); + await tester.tap(find.text('External downloader: ADM')); + await tester.pumpAndSettle(); + expect(selected, ['ADM']); + }); + + for (final status in [TaskStatus.paused, TaskStatus.failed]) { + testWidgets( + '${status.name} exposes its labeled recovery action and readable failures', + (tester) async { + var attempts = 0; + final entry = DownloadEntry.fromTask( + DownloadTask(url: 'https://media.example/movie.mp4'), + taskStatus: status, + ); + Future recover() async { + attempts++; + throw StateError( + 'Cannot resume: this server does not support ranges.', + ); + } + + final state = await mountRow( + tester, + DownloadControls( + entry: entry, + onPause: () async {}, + onResume: recover, + onRetry: recover, + onCancel: () async {}, + ), + ); + addTearDown(state.dispose); + final label = status == TaskStatus.paused ? 'Resume' : 'Retry'; + expect(find.widgetWithText(FilledButton, label), findsOneWidget); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(attempts, 1); + expect( + find.text('Cannot resume: this server does not support ranges.'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'failed unknown-length transfer stops animating and shows its actual cause', + (tester) async { + final entry = DownloadEntry.fromTask( + DownloadTask(url: 'https://media.example/movie.mp4'), + taskStatus: TaskStatus.failed, + error: 'Connection refused by media server', + ); + final state = await mountRow( + tester, + DownloadTransferDetails(entry: entry), + ); + addTearDown(state.dispose); + expect( + tester + .widget( + find.byType(LinearProgressIndicator), + ) + .value, + 0, + ); + expect(find.textContaining('%'), findsNothing); + expect(find.text('Connection refused by media server'), findsOneWidget); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'download controls are labeled and dispatch once while an operation is pending', + (tester) async { + final pending = Completer(); + var pauses = 0; + var cancels = 0; + final entry = DownloadEntry.fromTask( + DownloadTask(url: 'https://media.example/movie.mp4'), + taskStatus: TaskStatus.running, + ); + final state = await mountRow( + tester, + DownloadControls( + entry: entry, + onPause: () { + pauses++; + return pending.future; + }, + onResume: () async {}, + onRetry: () async {}, + onCancel: () async { + cancels++; + }, + ), + ); + addTearDown(state.dispose); + expect(find.widgetWithText(FilledButton, 'Pause'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget); + await tester.tap(find.text('Pause')); + await tester.tap(find.text('Pause')); + await tester.pump(); + expect(pauses, 1); + expect( + tester + .widget( + find.widgetWithText(OutlinedButton, 'Cancel'), + ) + .onPressed, + isNull, + ); + pending.complete(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(cancels, 1); + expect(tester.takeException(), isNull); + }, + ); + + for (final status in { + TaskStatus.enqueued: 'Queued', + TaskStatus.running: 'Downloading', + TaskStatus.waitingToRetry: 'Waiting to retry', + TaskStatus.paused: 'Paused', + TaskStatus.complete: 'Downloaded', + TaskStatus.failed: 'Failed', + TaskStatus.canceled: 'Canceled', + TaskStatus.notFound: 'Source not found', + }.entries) { + testWidgets( + '${status.key.name} has a readable status and preserves measured progress', + (tester) async { + final entry = DownloadEntry.fromTask( + DownloadTask(url: 'https://media.example/movie.mp4'), + taskStatus: status.key, + progress: .25, + totalBytes: 4096, + speed: 2048, + error: status.key == TaskStatus.failed ? 'HTTP 403: expired URL' : '', + ); + final state = await mountRow( + tester, + DownloadTransferDetails(entry: entry), + ); + addTearDown(state.dispose); + expect(find.text(status.value), findsOneWidget); + expect( + tester + .widget( + find.byType(LinearProgressIndicator), + ) + .value, + status.key == TaskStatus.complete ? 1 : .25, + ); + expect( + find.textContaining('/s'), + status.key == TaskStatus.running ? findsOneWidget : findsNothing, + ); + if (status.key == TaskStatus.failed) { + expect(find.text('HTTP 403: expired URL'), findsOneWidget); + } + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'queued Wi-Fi download shows its constraint without invented progress', + (tester) async { + final entry = DownloadEntry.fromTask( + DownloadTask( + url: 'https://media.example/movie.mp4', + requiresWiFi: true, + ), + ); + final state = await mountRow( + tester, + DownloadTransferDetails(entry: entry), + ); + addTearDown(state.dispose); + expect(find.text('Queued · Wi-Fi required'), findsOneWidget); + expect(find.textContaining('0%'), findsNothing); + expect(find.textContaining('/s'), findsNothing); + expect( + tester + .widget( + find.byType(LinearProgressIndicator), + ) + .value, + isNull, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/download_ui_regression_test.dart b/test/download_ui_regression_test.dart new file mode 100644 index 0000000..c8a7153 --- /dev/null +++ b/test/download_ui_regression_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:reelbox/media/download_button.dart'; +import 'package:reelbox/media/downloads_page.dart'; + +import 'catalog_widget_test.dart' show CatalogApi, mountCatalog; + +void main() { + testWidgets( + 'embedded sources are rejected without enqueue or external handoff', + (tester) async { + var dispatched = 0; + var navigations = 0; + final state = await mountCatalog( + tester, + CatalogApi(), + Scaffold( + body: DownloadButton( + title: const TitleRecord(id: 7, title: 'Movie'), + source: const MediaSource( + type: 'youtube', + url: 'https://youtu.be/fixture', + ), + enqueue: ({required title, required source, episode}) async { + dispatched++; + }, + openDownloads: (_) => navigations++, + ), + ), + ); + addTearDown(state.dispose); + await tester.tap(find.text('Download in Reelbox')); + await tester.pumpAndSettle(); + expect(dispatched, 0); + expect(navigations, 0); + expect( + find.textContaining('Embedded sources cannot be downloaded'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('source cards label the internal Reelbox download action', ( + tester, + ) async { + final state = await mountCatalog( + tester, + CatalogApi(), + const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')), + ); + addTearDown(state.dispose); + expect( + find.widgetWithText(FilledButton, 'Download in Reelbox'), + findsWidgets, + ); + }); + + testWidgets( + 'enqueue failure shows its cause and allows retry without navigating', + (tester) async { + var attempts = 0; + var navigations = 0; + final state = await mountCatalog( + tester, + CatalogApi(), + Scaffold( + body: DownloadButton( + title: const TitleRecord(id: 7, title: 'Movie'), + source: const MediaSource(url: 'https://media.example/movie.mp4'), + enqueue: ({required title, required source, episode}) async { + attempts++; + throw StateError('HTTP 403: this media URL has expired.'); + }, + openDownloads: (_) => navigations++, + ), + ), + ); + addTearDown(state.dispose); + await tester.tap(find.text('Download in Reelbox')); + await tester.pumpAndSettle(); + expect( + find.textContaining('HTTP 403: this media URL has expired.'), + findsOneWidget, + ); + expect(navigations, 0); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Download in Reelbox')); + await tester.pump(); + expect(attempts, 2); + }, + ); + + testWidgets( + 'selected episode queues once while starting then opens Downloads', + (tester) async { + const title = TitleRecord(id: 41, title: 'Series', type: 'serie'); + const source = MediaSource( + id: 9, + quality: '720p', + url: 'https://media.example/episode.mp4', + ); + const episode = EpisodeRecord(id: 52, title: 'Episode 2'); + final pending = Completer(); + final dispatched = <(TitleRecord, MediaSource, EpisodeRecord?)>[]; + final state = await mountCatalog( + tester, + CatalogApi(), + Scaffold( + body: DownloadButton( + title: title, + source: source, + episode: episode, + enqueue: ({required title, required source, episode}) { + dispatched.add((title, source, episode)); + return pending.future; + }, + ), + ), + ); + addTearDown(state.dispose); + final button = find.widgetWithText(FilledButton, 'Download in Reelbox'); + await tester.tap(button); + await tester.tap(button); + await tester.pump(); + expect(dispatched, [(title, source, episode)]); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(DownloadsPage), findsNothing); + pending.complete(); + await tester.pumpAndSettle(); + expect(find.byType(DownloadsPage), findsOneWidget); + expect(dispatched, hasLength(1)); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); +} diff --git a/test/fixtures/curated.json b/test/fixtures/curated.json new file mode 100644 index 0000000..ef4edf1 --- /dev/null +++ b/test/fixtures/curated.json @@ -0,0 +1,10 @@ +[ + { + "title": [ + "ترسناک" + ], + "link": [ + "http://boxlimon.com/api-user/newapi/filter.php?action=pishnahad&garid=1&pageno=" + ] + } +] diff --git a/test/fixtures/movie.json b/test/fixtures/movie.json new file mode 100644 index 0000000..9e2b940 --- /dev/null +++ b/test/fixtures/movie.json @@ -0,0 +1,49 @@ +{ + "id": 35721, + "type": "movie", + "title": "The Canvas", + "description": "نام اصلی: The Canvas\r\n\r\nامتیاز فیلم: 5.2/ 10\r\n\r\nنام پارسی: تابلوی نفرین‌ شده \r\n\r\nژانر : ترسناک\r\n\r\nسال تولید : 2026\r\n \r\nمحصول : آمریکا , ایتالیا\r\n\r\nزبان : انگلیسی\r\n\r\nرده سنی : 16\r\n\r\nکارگردان: John Real\r\n\r\nبازیگران: Angus Macfadyen, Francesco Marzagalli, John Real ,Nicolette McKeown ,Sabrina Culver\r\n\r\nفرمت: mkv\r\n\r\nکیفیت : 720-----1080\r\n\r\nحجم: 734 مگابایت + 1.48 گیگابایت\r\n\r\n\r\nخلاصه داستان : \r\n\r\nدر خانه‌ای دورافتاده، یک پدر و پسرش نقاشی‌ای را کشف می‌کنند که به شکلی نگران‌کننده زنده به نظر می‌رسد. وقتی تاریک‌ترین کابوس‌های آن‌ها وارد واقعیت می‌شوند، حضوری خبیث و شوم از دل بوم نقاشی بیرون می‌آید و از ترومای مدفون‌ شده و خاطرات فراموش‌شده تغذیه می‌کند. \r\n\r\n\r\nزیرنویس چسبیده پارسی", + "year": 2026, + "imdb": 5.2, + "comment": true, + "rating": 5, + "duration": null, + "downloadas": "2", + "playas": "1", + "classification": null, + "image": "https://ninwinios.info/uploads/cache/poster_thumb/uploads/jpg/6d8522b17182b73b9d6925ce15e63e72.jpg", + "cover": "https://ninwinios.info/uploads/cache/cover_thumb/uploads/jpg/14a7f16e0381c30b03bf8a6bd29e12a0.jpg", + "genres": [ + { + "id": 1, + "title": "ترسناک" + } + ], + "sources": [ + { + "id": 520531, + "quality": "تیزر", + "type": "mp4", + "url": "http://peer.tci.cdn.146.dl150m.info/tizer/%D8%AF%D8%A7%D9%86%D9%84%D9%88%D8%AF%20%D9%81%DB%8C%D9%84%D9%85%20The%20Canvas%202026%20%D8%AA%D8%A7%D8%A8%D9%84%D9%88%DB%8C%20%D9%86%D9%81%D8%B1%DB%8C%D9%86%E2%80%8C%D8%B4%D8%AF%D9%87%20%D8%A8%D8%AF%D9%88%D9%86%20%D8%B3%D8%A7%D9%86%D8%B3%D9%88%D8%B1.mp4" + }, + { + "id": 520539, + "quality": "720 زیرنویس", + "type": "mkv", + "url": "http://peer.tci.cdn.146.dl150m.info/film1/The.Canvas.2026.720p.WEBRip.x264.AAC-YTS.SoftSub.mkv" + }, + { + "id": 520540, + "quality": "1080 زیرنویس", + "type": "mkv", + "url": "http://peer.tci.cdn.146.dl150m.info/film1/The.Canvas.2026.1080p.WEBRip.x264.AAC5.1-YTS.SoftSub.mkv" + } + ], + "country": [ + { + "id": 1, + "title": "امریکا", + "image": "https://ninwinios.info/uploads/cache/country_thumb/uploads/jpg/b8b281c0e76be9a34a53edefa904ca99.jpg" + } + ] +} diff --git a/test/fixtures/player_persian_cp1256.srt b/test/fixtures/player_persian_cp1256.srt new file mode 100644 index 0000000..740a9a2 --- /dev/null +++ b/test/fixtures/player_persian_cp1256.srt @@ -0,0 +1,3 @@ +1 +00:00:01,000 --> 00:00:03,000 +ÓáÇã ÏäíǺ �ÎÔ �� �Ž diff --git a/test/fixtures/search.json b/test/fixtures/search.json new file mode 100644 index 0000000..2ce3c76 --- /dev/null +++ b/test/fixtures/search.json @@ -0,0 +1,20 @@ +[ + { + "id": "32789", + "title": "Avatar Fire and Ash", + "year": "2026", + "type": "movie", + "imdb": "10", + "posted_id": "2324949", + "image": "http://simbaapp.info/uploads/cache/poster_thumb/uploads/jpg/f30c48e3f60d5addd2be49782d82d9f0.jpg" + }, + { + "id": "18602", + "title": "Avatar The Way of Water", + "year": "2022", + "type": "movie", + "imdb": "10", + "posted_id": "558704", + "image": "http://simbaapp.info/uploads/cache/poster_thumb/uploads/jpg/8b2c739617695430fffe9a297fd779cb.jpg" + } +] diff --git a/test/fixtures/season.json b/test/fixtures/season.json new file mode 100644 index 0000000..52c4555 --- /dev/null +++ b/test/fixtures/season.json @@ -0,0 +1,40 @@ +{ + "id": 4263, + "title": "فصل اول 480 زیرنویس", + "episodes": [ + { + "id": 72819, + "title": "1", + "description": "", + "duration": "", + "downloadas": "2", + "playas": "1", + "image": "https://ninwinios.info/uploads/cache/episode_thumb/uploads/jpg/1003.jpg", + "sources": [ + { + "id": 83260, + "quality": "", + "type": "", + "url": "http://uplink.tci2.giftmond.ir/tv/The%20I-Land/FASL1480/The.I-Land.S01E01.480p.WEB-DL.EasyTV.mkv" + } + ] + }, + { + "id": 72820, + "title": "2", + "description": "", + "duration": "", + "downloadas": "2", + "playas": "1", + "image": "https://ninwinios.info/uploads/cache/episode_thumb/uploads/jpg/1003.jpg", + "sources": [ + { + "id": 83261, + "quality": "", + "type": "", + "url": "http://uplink.tci2.giftmond.ir/tv/The%20I-Land/FASL1480/The.I-Land.S01E02.480p.WEB-DL.EasyTV.mkv" + } + ] + } + ] +} diff --git a/test/fixtures/serie.json b/test/fixtures/serie.json new file mode 100644 index 0000000..b7a739b --- /dev/null +++ b/test/fixtures/serie.json @@ -0,0 +1,31 @@ +{ + "id": 35716, + "title": "A Friend A Murderer", + "type": "serie", + "description": "نام اصلی سریال : A Friend A Murderer\r\n\r\nامتیاز سریال : 6.5/10\r\n\r\nنام پارسی سریال : یک دوست یک قاتل\r\n\r\nژانر : جنایی , مستند\r\n\r\nتاریخ انتشار: 2026\r\n\r\nمحصول کشور: آمریکا\r\n\r\nزبان: انگلیسی\r\n\r\nرده سنی: 16\r\n\r\nتعداد قسمت ها: 3 قسمت\r\n\r\nبازیگران: Magnus Fjeldsted Bach , Lasse Reimer\r\n\r\nفرمت: mkv\r\n\r\nکیفیت 720\r\n\r\nحجم: کیفیت 720 (502------602 مگابایت )\r\n\r\n\r\nخلاصه سریال : \r\n\r\nیک شهر در دانمارک به مدت هشت سال از دست یک مجرم سریالی در هراس بود. پس از دستگیری این فرد، آماندا، نیکلاس و کیری دریافتند که او همان ....\r\n\r\n\r\nزیرنویس چسبیده پارسی\r\n\r\n\r\nوضعیت مینی سریال: به پایان رسیده است", + "year": 2026, + "rating": 3, + "imdb": 6.5, + "comment": true, + "duration": "1 فصل", + "downloadas": null, + "classification": "N/A", + "image": "https://ninwinios.info/uploads/cache/poster_thumb/uploads/jpg/0ef8153ccfed80cb115f42183fb97e75.jpg", + "cover": "https://ninwinios.info/uploads/cache/cover_thumb/uploads/jpg/3fcbb1c3bddab31227b9ee0b82a1f536.jpg", + "genres": [ + { + "id": 7, + "title": "جنایی" + }, + { + "id": 15, + "title": "مستند" + } + ], + "country": [ + { + "id": 1, + "title": "امریکا" + } + ] +} diff --git a/test/home_banner_test.dart b/test/home_banner_test.dart new file mode 100644 index 0000000..f62fd8b --- /dev/null +++ b/test/home_banner_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/home_banners.dart'; + +class BannerApi extends ApiService { + String? requested; + dynamic response; + bool fail = false; + @override + Future get(String path, {Map? query}) async { + requested = path; + if (fail) throw const ApiException('Offline'); + return response; + } +} + +void main() { + test('real trusted transport preserves configured ads without restoring vendor feeds', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final paths = []; + server.listen((request) async { + paths.add(request.uri.path); + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'banners': [ + { + 'title_en': 'Fixture sponsor', + 'target_url': 'https://sponsor.example/offer', + }, + { + 'title_en': 'Melodybox fixture', + 'target_url': 'https://vendor.example/', + }, + { + 'title_en': 'Blocked link', + 'target_url': 'https://t.me/OtherChannel', + }, + ], + }), + ); + await request.response.close(); + }); + final api = ApiService(baseUrl: 'http://127.0.0.1:${server.port}/api/'); + final banners = await HomeBannerService(api).load(); + expect(paths, ['/api/banners/']); + expect(banners, hasLength(2)); + expect(banners.first, HomeBanner.telegram); + expect(banners.last.title(false), 'Fixture sponsor'); + expect(banners.last.isAdvertisement, isTrue); + }); + test( + 'unsafe ads are skipped independently and config work is bounded', + () async { + final api = BannerApi(); + final valid = { + 'title_en': 'Fixture sponsor', + 'target_url': 'https://sponsor.example/', + }; + for (final target in [ + 'http://sponsor.example/', + 'javascript:alert(1)', + '//sponsor.example/', + 'https://user:pass@sponsor.example/', + 'https://sponsor.example:444/', + 'https://sponsor.example/ bad', + 'https://sponsor.example/\\\\evil', + 'https://', + 'https://t.me/OtherChannel', + 'https://sponsor.example/app.apk', + 'https://sponsor.example/%0aevil', + 'https://-bad.example/', + 'https://bad..example/', + 'https://bad.example./', + 'https://sponsor.example/%zz', + 'https://sponsor.example/${'x' * 2048}', + ]) { + api.response = { + 'banners': [ + {...valid, 'target_url': target}, + valid, + ], + }; + final banners = await HomeBannerService(api).load(); + expect(banners, hasLength(2), reason: target); + expect(banners.last.target.toString(), 'https://sponsor.example/'); + } + for (final invalid in [ + null, + true, + 4, + [], + {}, + {'banners': {}}, + { + 'banners': [null, 7, true, {}], + }, + ]) { + api.response = invalid; + expect(await HomeBannerService(api).load(), [HomeBanner.telegram]); + } + api.response = { + 'banners': [ + {...valid, 'title_en': 'x' * 81}, + {...valid, 'title_en': 'bad\u202etext'}, + {...valid, 'subtitle_en': 'x' * 181}, + {...valid, 'title_en': 5}, + {...valid, 'image_url': 'http://sponsor.example/image.jpg'}, + valid, + ], + }; + expect(await HomeBannerService(api).load(), hasLength(2)); + api.response = {'banners': List.filled(1000, valid)}; + expect(await HomeBannerService(api).load(), hasLength(6)); + api.response = { + 'banners': [...List.filled(20, {}), valid], + }; + expect(await HomeBannerService(api).load(), [HomeBanner.telegram]); + }, + ); + test( + 'configured bilingual ads append after Telegram with optional image', + () async { + final api = BannerApi() + ..response = { + 'banners': [ + { + 'title_en': 'Test sponsor', + 'title_fa': 'حامی آزمایشی', + 'subtitle_en': 'Fixture only', + 'subtitle_fa': 'فقط دادهٔ آزمایشی', + 'target_url': 'https://sponsor.example/offer?ref=reelbox', + 'image_url': 'https://sponsor.example/banner.jpg', + }, + { + 'title_en': 'Text sponsor', + 'target_url': 'https://sponsor.example/text', + }, + ], + }; + final banners = await HomeBannerService(api).load(); + expect(banners, hasLength(3)); + expect(banners.first, same(HomeBanner.telegram)); + expect(banners[1].isAdvertisement, isTrue); + expect(banners[1].title(true), 'حامی آزمایشی'); + expect(banners[1].subtitle(true), 'فقط دادهٔ آزمایشی'); + expect(banners[1].image.toString(), 'https://sponsor.example/banner.jpg'); + expect(banners.last.title(true), 'Text sponsor'); + expect(banners.last.image, isNull); + }, + ); + test( + 'default Telegram banner survives missing config and API failures', + () async { + final api = BannerApi(); + final service = HomeBannerService(api); + for (final fail in [false, true]) { + api.fail = fail; + final banners = await service.load(); + expect(banners, hasLength(1)); + expect(banners.single.target.toString(), 'https://t.me/ReelBoxApp'); + expect(banners.single.title(false), 'ReelBox updates'); + expect(banners.single.subtitle(false), 'Updates and announcements'); + expect(banners.single.title(true), 'تازه‌های ریل‌باکس'); + expect(banners.single.isAdvertisement, isFalse); + } + expect(api.requested, 'banners/'); + }, + ); +} diff --git a/test/home_banner_widget_test.dart b/test/home_banner_widget_test.dart new file mode 100644 index 0000000..96014c2 --- /dev/null +++ b/test/home_banner_widget_test.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class HomeBannerFixtureApi extends ApiService { + bool fail = false; + Completer? pending; + dynamic bannerResponse = {'banners': []}; + int bannerCalls = 0; + @override + Future get(String path, {Map? query}) async { + if (path != 'banners/') return []; + bannerCalls++; + if (fail) throw const ApiException('Offline'); + return pending == null ? bannerResponse : pending!.future; + } + + @override + Future initialize({bool discover = true}) async {} + @override + Future> home() async { + if (fail) throw const ApiException('Offline'); + return {'genres': [], 'actors': [], 'countries': []}; + } + + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => []; + @override + Future> countries() async => []; + @override + Future> genres() async => []; +} + +Future mountBannerHome( + WidgetTester tester, + HomeBannerFixtureApi api, { + String locale = 'en', +}) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: api, + storage: await SharedPreferences.getInstance(), + ); + await state.initialize(discover: false); + await state.updateSettings( + state.prefs.copyWith(locale: locale, introCompleted: true), + ); + await tester.pumpWidget(MyApp(state: state)); + await tester.pumpAndSettle(); + return state; +} + +void main() { + testWidgets( + 'pending config keeps Telegram then labels bilingual ads and honors data saver', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final api = HomeBannerFixtureApi()..pending = Completer(); + final state = await mountBannerHome(tester, api); + expect(find.text('ReelBox updates'), findsOneWidget); + expect(find.text('Advertisement'), findsNothing); + api.pending!.complete({ + 'banners': [ + { + 'title_en': 'Fixture sponsor', + 'title_fa': 'حامی آزمایشی', + 'target_url': 'https://sponsor.example/offer', + 'image_url': 'https://sponsor.example/banner.jpg', + }, + ], + }); + await tester.pumpAndSettle(); + expect(find.text('Advertisement'), findsOneWidget); + expect(find.text('Fixture sponsor'), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + expect(find.text('ReelBox updates'), findsOneWidget); + final launches = []; + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + launches.add(call.arguments['url'] as String); + return true; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.tap(find.byKey(const Key('home-banner-ad-1'))); + await tester.pumpAndSettle(); + expect(launches, ['https://sponsor.example/offer']); + await state.updateSettings( + state.prefs.copyWith(locale: 'fa', dataSaver: true), + ); + await tester.pumpAndSettle(); + expect(find.text('تبلیغات'), findsOneWidget); + expect(find.text('حامی آزمایشی'), findsOneWidget); + expect(find.byType(Image), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }, + ); + + testWidgets( + 'failed external launch shows safe error and keeps official banner', + (tester) async { + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => false, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final state = await mountBannerHome(tester, HomeBannerFixtureApi()); + await tester.tap(find.byKey(const Key('home-banner-telegram'))); + await tester.pump(); + expect(find.text('Could not open this link'), findsOneWidget); + expect(find.text('ReelBox updates'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }, + ); + testWidgets( + 'home Telegram is immediately clickable through API failure in both languages', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final launches = []; + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + launches.add(call); + return true; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final api = HomeBannerFixtureApi()..fail = true; + final state = await mountBannerHome(tester, api); + expect(find.byType(DiscoverPage), findsOneWidget); + expect(find.text('ReelBox updates'), findsOneWidget); + expect(find.text('Updates and announcements'), findsOneWidget); + expect(find.text('Offline'), findsWidgets); + await tester.tap(find.byKey(const Key('home-banner-telegram'))); + await tester.pumpAndSettle(); + expect(launches.single.method, 'launch'); + expect(launches.single.arguments['url'], 'https://t.me/ReelBoxApp'); + expect(launches.single.arguments['useWebView'], false); + await state.updateSettings(state.prefs.copyWith(locale: 'fa')); + await tester.pumpAndSettle(); + expect(find.text('تازه‌های ریل‌باکس'), findsOneWidget); + expect(find.text('به‌روزرسانی‌ها و اطلاعیه‌ها'), findsOneWidget); + expect( + Directionality.of(tester.element(find.text('تازه‌های ریل‌باکس'))), + TextDirection.rtl, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }, + ); +} diff --git a/test/library_cleanup_mobile_test.dart b/test/library_cleanup_mobile_test.dart new file mode 100644 index 0000000..dc8c43a --- /dev/null +++ b/test/library_cleanup_mobile_test.dart @@ -0,0 +1,153 @@ +// Mobile layout checks; optional captures are host-rendered, not device evidence. +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/library.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _LocalApi extends ApiService { + @override + Future initialize({bool discover = true}) async {} + @override + Future> home() async => { + 'genres': [], + 'actors': [], + 'countries': [], + }; + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => []; + @override + Future> countries() async => []; +} + +void main() { + for (final locale in ['en', 'fa']) { + testWidgets( + 'mobile intro, privacy and useful settings remain reachable in $locale', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final output = Platform.environment['REELBOX_CLEANUP_SCREENSHOTS']; + if (output != null) { + await tester.runAsync(() async { + final bytes = await File( + Platform.environment['REELBOX_TEST_FONT'] ?? + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + ).readAsBytes(); + for (final name in ['Roboto', 'Ahem']) { + await (FontLoader( + name, + )..addFont(Future.value(bytes.buffer.asByteData()))).load(); + } + final icons = await File( + Platform.environment['REELBOX_ICON_FONT'] ?? '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf', + ).readAsBytes(); + await (FontLoader( + 'MaterialIcons', + )..addFont(Future.value(icons.buffer.asByteData()))).load(); + await Directory(output).create(recursive: true); + }); + } + SharedPreferences.setMockInitialValues({}); + final state = AppState(api: _LocalApi()); + addTearDown(state.dispose); + await state.initialize(discover: false); + await state.updateSettings(state.prefs.copyWith(locale: locale)); + final boundaryKey = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: MyApp(state: state), + ), + ); + await tester.pumpAndSettle(); + Future capture(String name) async { + expect(tester.takeException(), isNull); + if (output == null) return; + final boundary = + boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await File('$output/$name-$locale.png') + .writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); + } + + String label(String en, String fa) => locale == 'en' ? en : fa; + Future reveal(String text) async { + final scrollable = find.byType(Scrollable).hitTestable().first; + await tester.scrollUntilVisible( + find.text(text), + 350, + scrollable: scrollable, + ); + await tester.ensureVisible(find.text(text)); + await tester.pumpAndSettle(); + } + + expect(find.byType(FirstRunIntroPage), findsOneWidget); + await capture('intro'); + await tester.tap(find.byKey(const Key('intro-skip'))); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text(label('More', 'بیشتر')), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MorePage), findsOneWidget); + await capture('more'); + await reveal(label('Privacy & your data', 'حریم خصوصی و اطلاعات شما')); + await tester.tap( + find.text(label('Privacy & your data', 'حریم خصوصی و اطلاعات شما')), + ); + await tester.pumpAndSettle(); + await capture('privacy-top'); + await reveal(label('Unencrypted media', 'رسانه بدون رمزنگاری')); + expect(find.textContaining('HTTP'), findsWidgets); + await capture('privacy-bottom'); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + await reveal(label('Settings', 'تنظیمات')); + await tester.tap(find.text(label('Settings', 'تنظیمات'))); + await tester.pumpAndSettle(); + expect(find.byType(SettingsPage), findsOneWidget); + expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing); + await capture('settings-top'); + final dataSaver = label('Data saver artwork', 'کاهش مصرف داده تصاویر'); + await reveal(dataSaver); + await tester.tap(find.text(dataSaver)); + await tester.pumpAndSettle(); + expect(state.prefs.dataSaver, true); + await reveal(label('Clear disposable cache', 'پاک کردن حافظه موقت')); + expect( + find.text(label('Export local library', 'خروجی کتابخانه محلی')), + findsOneWidget, + ); + expect( + find.text(label('Import local library', 'ورود کتابخانه محلی')), + findsOneWidget, + ); + await capture('settings-data'); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } +} diff --git a/test/library_identity_regression_test.dart b/test/library_identity_regression_test.dart new file mode 100644 index 0000000..ac7d78c --- /dev/null +++ b/test/library_identity_regression_test.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const movie = TitleRecord(id: 42, title: 'Movie'); +const channel = TitleRecord(id: 42, title: 'Channel', type: 'channel'); + +class LocalLibraryApi extends ApiService { + @override + Future initialize({bool discover = true}) async {} +} + +// Construct the old backup directly: current mutations must never save channels. +String legacyLibrary(AppState state) { + final document = jsonDecode(state.exportLibrary()) as Map; + document['saved'] = [channel.toJson(), movie.toJson()]; + document['history'] = [ + WatchEntry(title: channel, positionSeconds: 30).toJson(), + WatchEntry(title: movie, positionSeconds: 15).toJson(), + ]; + document['collections'] = [ + LocalCollection( + id: 'both', + name: 'Both', + titles: [channel, movie], + ).toJson(), + ]; + return jsonEncode(document); +} + +void expectMovieOnly(AppState state) { + expect(state.saved.single.toJson(), movie.toJson()); + expect(state.hasSaved(42), isTrue); + expect(state.hasSaved(42, kind: 'channel'), isFalse); + expect(state.history.single.title.toJson(), movie.toJson()); + expect(state.resumeSeconds(42), 15); + expect(state.resumeSeconds(42, kind: 'channel'), 0); + expect(state.collections.single.id, 'both'); + expect(state.collections.single.name, 'Both'); + expect(state.collections.single.titles.single.toJson(), movie.toJson()); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final viaStorage in [false, true]) { + test( + 'legacy ${viaStorage ? 'storage migration' : 'import'} prunes channels without losing same-ID movies', + () async { + final store = await SharedPreferences.getInstance(); + final state = AppState(api: LocalLibraryApi(), storage: store); + final restored = AppState(api: LocalLibraryApi(), storage: store); + addTearDown(state.dispose); + addTearDown(restored.dispose); + final legacy = legacyLibrary(state); + if (viaStorage) { + await store.setString(AppState.storageKey, legacy); + await state.initialize(discover: false); + } else { + await state.importLibrary(legacy); + } + expectMovieOnly(state); + final sanitized = state.exportLibrary(); + expect((jsonDecode(sanitized) as Map)['version'], 1); + expect(store.getString(AppState.storageKey), sanitized); + await restored.initialize(discover: false); + expectMovieOnly(restored); + expect(restored.exportLibrary(), sanitized); + await restored.removeSaved(42, kind: 'channel'); + await restored.removeHistory(42, kind: 'channel'); + expectMovieOnly(restored); + await restored.removeSaved(42); + await restored.removeHistory(42); + expect(restored.saved, isEmpty); + expect(restored.history, isEmpty); + expect( + restored.collections.single.titles.single.toJson(), + movie.toJson(), + ); + }, + ); + } + + test('blocked collection mutations cannot replace a same-ID movie', () async { + final state = AppState(api: LocalLibraryApi()); + addTearDown(state.dispose); + final collection = await state.createCollection('Both'); + await state.toggleCollectionTitle(collection.id, movie); + final original = state.exportLibrary(); + await state.toggleCollectionTitle(collection.id, channel); + await state.toggleCollectionTitle(collection.id, channel); + expect(state.exportLibrary(), original); + expect(state.collections.single.titles.single.toJson(), movie.toJson()); + await state.toggleCollectionTitle(collection.id, movie); + await state.toggleCollectionTitle(collection.id, channel); + expect(state.collections.single.titles, isEmpty); + }); + + test( + 'blocked history mutations cannot replace same-ID movie progress', + () async { + final state = AppState(api: LocalLibraryApi()); + addTearDown(state.dispose); + await state.recordWatch(movie, positionSeconds: 15); + final original = state.exportLibrary(); + await state.recordWatch(channel, positionSeconds: 30); + expect(state.exportLibrary(), original); + expect(state.history.single.title.toJson(), movie.toJson()); + expect(state.resumeSeconds(42), 15); + expect(state.resumeSeconds(42, kind: 'channel'), 0); + await state.removeHistory(42, kind: 'channel'); + expect(state.resumeSeconds(42), 15); + await state.removeHistory(42); + await state.recordWatch(channel, positionSeconds: 30); + expect(state.history, isEmpty); + }, + ); + + test('blocked watchlist mutations cannot replace a same-ID movie', () async { + final store = await SharedPreferences.getInstance(); + final state = AppState(api: LocalLibraryApi(), storage: store); + addTearDown(state.dispose); + await state.toggleSaved(movie); + final original = state.exportLibrary(); + var notifications = 0; + state.addListener(() => notifications++); + await state.toggleSaved(channel); + await state.toggleSaved(channel); + expect(state.exportLibrary(), original); + expect(store.getString(AppState.storageKey), original); + expect(notifications, 0); + expect(state.saved.single.toJson(), movie.toJson()); + expect(state.hasSaved(42), isTrue); + expect(state.hasSaved(42, kind: 'channel'), isFalse); + await state.removeSaved(42, kind: 'channel'); + expect(state.saved.single.toJson(), movie.toJson()); + await state.removeSaved(42); + await state.toggleSaved(channel); + expect(state.saved, isEmpty); + expect(state.hasSaved(42, kind: 'channel'), isFalse); + }); + + test( + 'legacy version-one titles without type still load as posters', + () async { + final state = AppState(api: LocalLibraryApi()); + addTearDown(state.dispose); + final document = + jsonDecode(state.exportLibrary()) as Map; + document['saved'] = [ + channel.toJson(), + {'id': 42, 'title': 'Legacy movie'}, + ]; + await state.importLibrary(jsonEncode(document)); + expect(state.hasSaved(42), isTrue); + expect(state.hasSaved(42, kind: 'channel'), isFalse); + expect(state.saved.single.libraryKey, 'poster:42'); + expect(state.saved.single.title, 'Legacy movie'); + final original = state.exportLibrary(); + await state.toggleSaved(channel); + expect(state.exportLibrary(), original); + }, + ); + + test( + 'movie and series aliases share the poster namespace during mutation', + () async { + final state = AppState(api: LocalLibraryApi()); + addTearDown(state.dispose); + const series = TitleRecord(id: 42, title: 'Series', type: 'series'); + const serie = TitleRecord(id: 42, title: 'Serie', type: 'serie'); + expect(movie.libraryKey, series.libraryKey); + expect(series.libraryKey, serie.libraryKey); + await state.toggleSaved(series); + expect(state.hasSaved(42, kind: 'serie'), isTrue); + await state.toggleSaved(serie); + expect(state.saved, isEmpty); + await state.recordWatch(series, episodeId: 7, positionSeconds: 10); + await state.recordWatch(serie, episodeId: 7, positionSeconds: 20); + expect(state.history.single.positionSeconds, 20); + }, + ); + + for (final section in ['saved', 'history', 'collections']) { + test( + '$section rejects same-namespace duplicate imports atomically', + () async { + final store = await SharedPreferences.getInstance(); + final state = AppState(api: LocalLibraryApi(), storage: store); + addTearDown(state.dispose); + await state.toggleSaved(movie); + final original = state.exportLibrary(); + var notifications = 0; + state.addListener(() => notifications++); + final document = jsonDecode(original) as Map; + const series = TitleRecord(id: 42, title: 'Series', type: 'serie'); + if (section == 'history') { + document[section] = [ + WatchEntry(title: channel).toJson(), + WatchEntry(title: movie).toJson(), + WatchEntry(title: series).toJson(), + ]; + } else if (section == 'collections') { + document[section] = [ + LocalCollection( + id: 'both', + name: 'Both', + titles: [channel, movie, series], + ).toJson(), + ]; + } else { + document[section] = [ + channel.toJson(), + movie.toJson(), + series.toJson(), + ]; + } + await expectLater( + state.importLibrary(jsonEncode(document)), + throwsFormatException, + ); + expect(state.exportLibrary(), original); + expect(store.getString(AppState.storageKey), original); + expect(notifications, 0); + }, + ); + } +} diff --git a/test/library_settings_regression_test.dart b/test/library_settings_regression_test.dart new file mode 100644 index 0000000..e548893 --- /dev/null +++ b/test/library_settings_regression_test.dart @@ -0,0 +1,325 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/common.dart'; +import 'package:reelbox/ui/library.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const movie = TitleRecord(id: 42, title: 'Movie'); +const channel = TitleRecord(id: 42, title: 'Channel', type: 'channel'); + +class SettingsLocalApi extends ApiService { + int discoveryChecks = 0; + @override + Future initialize({bool discover = true}) async { + if (discover) discoveryChecks++; + } +} + +Future mount(WidgetTester tester, AppState state, Widget page) async { + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp(home: page), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('imported non-menu settings remain selected and editable', ( + tester, + ) async { + final state = AppState(api: SettingsLocalApi()); + addTearDown(state.dispose); + final backup = jsonDecode(state.exportLibrary()) as Map; + backup['settings'] = { + ...state.prefs.toJson(), + 'defaultQuality': '2160p', + 'playbackSpeed': 1.75, + 'skipSeconds': 12, + 'subtitleColor': 0xFF12AB34, + 'subtitleBackground': 0x40123456, + }; + await state.importLibrary(jsonEncode(backup)); + final original = state.prefs.toJson(); + await mount(tester, state, const SettingsPage()); + for (final setting in { + 'Default quality': '2160p', + 'Playback speed': 1.75, + 'Seek step': 12, + 'Subtitle color': 0xFF12AB34, + 'Subtitle background': 0x40123456, + }.entries) { + await tester.scrollUntilVisible( + find.text(setting.key), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final tile = find.ancestor( + of: find.text(setting.key), + matching: find.byType(ListTile), + ); + final dropdown = tester.widget>( + find.descendant( + of: tile, + matching: find.byWidgetPredicate( + (widget) => widget is DropdownButton, + ), + ), + ); + expect(dropdown.value, setting.value); + expect( + dropdown.items!.where((item) => item.value == setting.value).length, + 1, + ); + } + expect(state.prefs.toJson(), original); + await tester.scrollUntilVisible( + find.text('Playback speed'), + -250, + scrollable: find.byType(Scrollable).first, + ); + final speedTile = find.ancestor( + of: find.text('Playback speed'), + matching: find.byType(ListTile), + ); + await tester.ensureVisible(speedTile); + await tester.tap( + find.descendant( + of: speedTile, + matching: find.byWidgetPredicate((widget) => widget is DropdownButton), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('1×').last); + await tester.pumpAndSettle(); + expect(state.prefs.playbackSpeed, 1.0); + expect(state.prefs.defaultQuality, '2160p'); + }); + + for (final locale in ['en', 'fa']) { + testWidgets( + 'settings keep useful controls without endpoint or technical clutter in $locale', + (tester) async { + final api = SettingsLocalApi(); + final state = AppState(api: api); + addTearDown(state.dispose); + await state.initialize(discover: false); + await state.updateSettings(state.prefs.copyWith(locale: locale)); + await mount(tester, state, const SettingsPage()); + final labels = {}; + final scrollable = find.byType(Scrollable).first; + for (var step = 0; step < 40; step++) { + labels.addAll( + tester.widgetList(find.byType(Text)).map((w) => w.data ?? ''), + ); + expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing); + final position = tester.state(scrollable).position; + if (position.pixels >= position.maxScrollExtent) break; + await tester.drag(scrollable, const Offset(0, -400)); + await tester.pumpAndSettle(); + } + final copy = labels.join('\n'); + expect(copy, isNot(contains(ApiService.iranBaseUrl))); + expect( + copy, + isNot( + matches( + RegExp( + r'API|Iran Catalogue|فهرست ایران|Parallel download segments|بخش‌های همزمان دانلود|Player volume|بلندی صدای پخش', + ), + ), + ), + ); + for (final label + in locale == 'en' + ? [ + 'Language', + 'Theme', + 'Resume from last position', + 'Enable subtitles', + 'Download on Wi-Fi only', + 'Export local library', + 'Import local library', + 'Clear disposable cache', + ] + : [ + 'زبان', + 'پوسته', + 'ادامه از آخرین موقعیت', + 'نمایش زیرنویس', + 'دانلود فقط با وای‌فای', + 'خروجی کتابخانه محلی', + 'ورود کتابخانه محلی', + 'پاک کردن حافظه موقت', + ]) { + expect(labels, contains(label)); + } + for (final label + in locale == 'en' + ? ['Export local library', 'Import local library'] + : ['خروجی کتابخانه محلی', 'ورود کتابخانه محلی']) { + final tile = find.ancestor( + of: find.text(label), + matching: find.byType(ListTile), + ); + expect(tester.widget(tile).onTap, isNotNull); + } + expect(api.discoveryChecks, 0); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('More opens a factual privacy and local-data explanation', ( + tester, + ) async { + final state = AppState(api: SettingsLocalApi()); + addTearDown(state.dispose); + await mount(tester, state, const MorePage()); + await tester.scrollUntilVisible( + find.text('Privacy & your data'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('Privacy & your data')); + await tester.pumpAndSettle(); + expect(find.text('External services'), findsOneWidget); + final disclosures = {}; + for (final topic in [ + 'On this device', + 'External services', + 'Sharing & backups', + 'Deleting local data', + 'Unencrypted media', + ]) { + await tester.scrollUntilVisible( + find.text(topic), + 250, + scrollable: find.byType(Scrollable).first, + ); + disclosures.addAll( + tester.widgetList(find.byType(Text)).map((w) => w.data ?? ''), + ); + expect(find.text(topic), findsOneWidget); + } + final copy = disclosures.join('\n'); + for (final disclosure in [ + 'No account needed', + 'recent searches', + 'IP address', + 'access tokens', + 'outside Reelbox’s control', + 'completed downloads', + 'unencrypted HTTP', + 'observe or modify', + ]) { + expect(copy, contains(disclosure)); + } + expect(copy, isNot(contains('API'))); + expect(tester.takeException(), isNull); + }); + + testWidgets('notifications explain new-task scope and Android requirements', ( + tester, + ) async { + final state = AppState(api: SettingsLocalApi()); + addTearDown(state.dispose); + await mount(tester, state, const SettingsPage()); + await tester.scrollUntilVisible( + find.text('Notifications for new downloads'), + 400, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Notifications for new downloads'), findsOneWidget); + expect( + find.textContaining('Running downloads keep their notification settings'), + findsOneWidget, + ); + expect( + find.textContaining( + 'Android may still require a foreground notification', + ), + findsOneWidget, + ); + }); + + testWidgets( + 'legacy channel history is pruned while same-ID movie remains removable', + (tester) async { + final state = AppState(api: SettingsLocalApi()); + addTearDown(state.dispose); + final backup = jsonDecode(state.exportLibrary()) as Map; + backup['history'] = [ + WatchEntry(title: channel, positionSeconds: 30).toJson(), + WatchEntry(title: movie, positionSeconds: 15).toJson(), + ]; + await state.importLibrary(jsonEncode(backup)); + await mount(tester, state, const LibraryPage()); + expect(find.text('Watchlist (0)'), findsOneWidget); + expect(find.text('Continue (1)'), findsOneWidget); + await tester.tap(find.text('Continue (1)')); + await tester.pumpAndSettle(); + expect(find.text('Channel'), findsNothing); + expect(find.text('Movie'), findsOneWidget); + expect(state.history.single.title.toJson(), movie.toJson()); + expect(state.resumeSeconds(42), 15); + expect(state.resumeSeconds(42, kind: 'channel'), 0); + await tester.tap(find.byTooltip('Remove history')); + await tester.pumpAndSettle(); + expect(state.history, isEmpty); + expect(find.text('Continue (0)'), findsOneWidget); + expect(find.text('Pick up where you left off'), findsOneWidget); + }, + ); + + testWidgets( + 'removed channel card has no bookmark action and preserves the same-ID movie', + (tester) async { + final state = AppState(api: SettingsLocalApi()); + addTearDown(state.dispose); + await state.toggleSaved(movie); + await mount( + tester, + state, + const Scaffold( + body: SizedBox(height: 244, child: PosterCard(title: channel)), + ), + ); + expect(find.text('Channel'), findsNothing); + expect(find.byTooltip('Watchlist'), findsNothing); + expect(find.byIcon(Icons.bookmark_border_rounded), findsNothing); + expect(find.byIcon(Icons.bookmark_rounded), findsNothing); + expect(state.saved.single.toJson(), movie.toJson()); + expect(state.hasSaved(42, kind: 'channel'), isFalse); + + // Allowed cards still expose the real bookmark action for this ID. + await mount( + tester, + state, + const Scaffold( + body: SizedBox(height: 244, child: PosterCard(title: movie)), + ), + ); + expect(find.text('Movie'), findsWidgets); + expect(find.byIcon(Icons.bookmark_rounded), findsOneWidget); + await tester.tap(find.byTooltip('Watchlist')); + await tester.pumpAndSettle(); + expect(state.saved, isEmpty); + expect(find.byIcon(Icons.bookmark_border_rounded), findsOneWidget); + await tester.tap(find.byTooltip('Watchlist')); + await tester.pumpAndSettle(); + expect(state.saved.single.toJson(), movie.toJson()); + expect(find.byIcon(Icons.bookmark_rounded), findsOneWidget); + }, + ); +} diff --git a/test/local_media_server_test.dart b/test/local_media_server_test.dart new file mode 100644 index 0000000..82eedfd --- /dev/null +++ b/test/local_media_server_test.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/local_media_server.dart'; + +void main() { + late Directory root; + late File video; + late HttpClient client; + final sessions = []; + + setUp(() async { + root = await Directory(Platform.environment['TMPDIR'] ?? Directory.systemTemp.path) + .createTemp('reelbox-lan-test-'); + video = await File('${root.path}/movie.mp4').writeAsString('0123456789'); + client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + }); + + tearDown(() async { + client.close(force: true); + for (final session in sessions) { + await session.close(); + } + sessions.clear(); + await root.delete(recursive: true); + }); + + Future start({File? file, File? subtitle, Map extras = const {}}) async { + final session = await LocalMediaServer.start( + file: file ?? video, + subtitle: subtitle, + extraFiles: extras, + bindAddress: InternetAddress.loopbackIPv4, + advertisedHost: '127.0.0.1', + ); + sessions.add(session); + return session; + } + + Future<({HttpClientResponse response, List bytes})> request( + Uri uri, {String method = 'GET', String? range} + ) async { + final request = await client.openUrl(method, uri); + if (range != null) request.headers.set(HttpHeaders.rangeHeader, range); + final response = await request.close(); + final bytes = await response.fold>([], (all, chunk) => all..addAll(chunk)); + return (response: response, bytes: bytes); + } + + test('explicit session streams video and HEAD with exact type and length', () async { + final session = await start(); + final get = await request(session.mediaUri); + expect(get.response.statusCode, 200); + expect(get.response.contentLength, 10); + expect(get.response.headers.contentType?.mimeType, 'video/mp4'); + expect(utf8.decode(get.bytes), '0123456789'); + final head = await request(session.mediaUri, method: 'HEAD'); + expect(head.response.statusCode, 200); + expect(head.response.contentLength, 10); + expect(head.bytes, isEmpty); + expect(session.subtitleUri, isNull); + }); + + test('subtitle shares an unguessable independent session token', () async { + final sub = await File('${root.path}/captions.vtt').writeAsString('WEBVTT\n'); + final first = await start(subtitle: sub); + final second = await start(); + expect(first.mediaUri.pathSegments.first, matches(RegExp(r'^[A-Za-z0-9_-]{43}$'))); + expect(first.subtitleUri!.pathSegments.first, first.mediaUri.pathSegments.first); + expect(second.mediaUri.pathSegments.first, isNot(first.mediaUri.pathSegments.first)); + final response = await request(first.subtitleUri!); + expect(response.response.headers.contentType?.mimeType, 'text/vtt'); + expect(utf8.decode(response.bytes), 'WEBVTT\n'); + }); + + test('range GET and HEAD expose exact selected bytes and headers', () async { + final session = await start(); + for (final value in <(String,String,String)>[ + ('bytes=2-5','2345','bytes 2-5/10'), + ('bytes=7-','789','bytes 7-9/10'), + ('bytes=-3','789','bytes 7-9/10'), + ('bytes=8-99','89','bytes 8-9/10'), + ]) { + final response = await request(session.mediaUri, range:value.$1); + expect(response.response.statusCode,206); + expect(utf8.decode(response.bytes),value.$2); + expect(response.response.headers.value('content-range'),value.$3); + expect(response.response.headers.value('accept-ranges'),'bytes'); + } + final head = await request(session.mediaUri,method:'HEAD',range:'bytes=2-5'); + expect(head.response.statusCode,206); + expect(head.response.contentLength,4); + expect(head.bytes,isEmpty); + }); + + test('invalid and unsupported multiple ranges return 416, not full video', () async { + final session = await start(); + for(final range in ['bytes=100-','bytes=6-3','bytes=-0','items=0-1','bytes=0-1,4-5']) { + final response=await request(session.mediaUri,range:range); + expect(response.response.statusCode,416,reason:range); + expect(response.response.headers.value('content-range'),'bytes */10'); + expect(response.bytes,isEmpty); + } + }); + + test('manifest extra resources are explicitly registered, with no directory access', () async { + final segment=await File('${root.path}/segment.ts').writeAsString('segment'); + final session=await start(extras:{'nested/part.ts':segment}); + expect(utf8.decode((await request(session.mediaUri.resolve('nested/part.ts'))).bytes),'segment'); + expect((await request(session.mediaUri.resolve('segment.ts'))).response.statusCode,404); + expect((await request(session.mediaUri.replace(path:'/wrong/media.mp4'))).response.statusCode,404); + expect((await request(session.mediaUri.resolve('../private'))).response.statusCode,404); + await expectLater(start(extras:{'../private':segment}),throwsArgumentError); + await expectLater(start(extras:{'media.mp4':segment}),throwsArgumentError); + }); + + test('registered paths cannot follow a symlink replaced after session creation', () async { + final secret=await File('${root.path}/private.mp4').writeAsString('secret'); + final link=await Link('${root.path}/selected.mp4').create(video.path); + final session=await start(file:File(link.path)); + expect((await request(session.mediaUri)).response.statusCode,200); + await link.delete(); await link.create(secret.path); + expect((await request(session.mediaUri)).response.statusCode,404); + }); + + test('methods are restricted and Cast preflight exposes only registered resources', () async { + final session=await start(); + final post=await request(session.mediaUri,method:'POST'); + expect(post.response.statusCode,405); + final options=await request(session.mediaUri,method:'OPTIONS'); + expect(options.response.statusCode,204); + expect(options.response.headers.value('access-control-allow-origin'),'*'); + expect(options.bytes,isEmpty); + }); + + test('close is idempotent and listening socket no longer accepts requests', () async { + final session = await start(); + await session.close(); + await session.close(); + expect(() => request(session.mediaUri), throwsA(isA())); + }); +} diff --git a/test/media_helpers_test.dart b/test/media_helpers_test.dart new file mode 100644 index 0000000..8fd8969 --- /dev/null +++ b/test/media_helpers_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:background_downloader/background_downloader.dart'; +import 'package:reelbox/media/download_service.dart'; + +import 'dart:convert'; + +import 'package:reelbox/media/media_helpers.dart'; + +void main() { + test('sources retain public variants, remove trailers and unusable URLs', () { + const a = MediaSource( + id: 1, + quality: '720p', + url: 'http://media.example/a.mp4?token=x', + ); + expect( + playableSources([ + a, + a, + const MediaSource(url: 'javascript:alert(1)'), + const MediaSource(quality: 'trailer', url: 'https://example.com/t.mp4'), + ]), + [a], + ); + }); + test( + 'source preference selects exact quality without inventing an access gate', + () { + const sd = MediaSource(quality: '480p', url: 'https://example.com/s.mp4'); + const hd = MediaSource( + quality: '1080p', + url: 'https://example.com/h.mp4', + ); + expect(selectSource([sd, hd], preferredQuality: '1080p'), hd); + expect(selectSource([sd, hd], preferredQuality: 'unavailable'), sd); + expect(selectSource([]), isNull); + }, + ); + test( + 'resume ignores completed and invalid positions and keeps exact identity', + () { + expect( + resumePosition( + position: const Duration(seconds: 30), + duration: const Duration(minutes: 20), + ), + const Duration(seconds: 30), + ); + expect( + resumePosition( + position: const Duration(seconds: -5), + duration: const Duration(minutes: 20), + ), + Duration.zero, + ); + expect( + resumePosition( + position: const Duration(seconds: 1190), + duration: const Duration(minutes: 20), + ), + Duration.zero, + ); + expect(watchKey(7, null), '7:movie'); + expect(watchKey(7, 12), '7:12'); + }, + ); + test('next episode stays ordered and does not silently skip an unavailable episode', () { + const episodes = [ + EpisodeRecord(id: 1), + EpisodeRecord(id: 2), + EpisodeRecord(id: 3), + ]; + expect(nextEpisode(episodes, 1)?.id, 2); + expect(nextEpisode(episodes, 3), isNull); + expect(nextEpisode(episodes, 99), isNull); + }); + test( + 'download names are private-path safe and stream manifests rejected', + () { + expect( + downloadFilename( + title: '../A/B: Film', + identity: '7_8', + url: 'https://example.com/movie.mkv?secret=a', + ), + 'A_B_ Film_7_8.mkv', + ); + expect( + downloadUnsupportedReason( + const MediaSource(url: 'https://example.com/master.m3u8'), + ), + isNotNull, + ); + expect( + downloadUnsupportedReason( + const MediaSource(url: 'https://example.com/a.mp4'), + ), + isNull, + ); + }, + ); + test('download metadata survives native task serialization with bounded progress', () { + final task = DownloadTask( + taskId: 'a', + url: 'http://example.com/video.mp4', + filename: 'video.mp4', + metaData: jsonEncode({ + 'title': 'Movie', + 'titleId': 17, + 'quality': '1080p', + 'episodeId': 8, + 'episodeTitle': 'Ep 8', + 'posterImage': 'poster', + }), + ); + final restored = DownloadTask.fromJson(task.toJson()); + final entry = DownloadEntry.fromTask( + restored, + taskStatus: TaskStatus.paused, + progress: -5, + totalBytes: 1000, + ); + expect(entry.title, 'Movie'); + expect(entry.titleId, 17); + expect(entry.episodeId, 8); + expect(entry.progress, 0); + expect(entry.canResume, isTrue); + expect(entry.isComplete, isFalse); + expect( + DownloadEntry.fromTask(task, taskStatus: TaskStatus.complete).progress, + 1, + ); + expect( + DownloadEntry.fromTask( + task, + taskStatus: TaskStatus.failed, + progress: 1.7, + ).progress, + 1, + ); + expect( + DownloadEntry.fromTask(task, taskStatus: TaskStatus.running).canPause, + isTrue, + ); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart new file mode 100644 index 0000000..5ca6204 --- /dev/null +++ b/test/models_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; + +Map fixture(String name) => + jsonDecode(File('test/fixtures/$name.json').readAsStringSync()) + as Map; + +void main() { + test('real series has no sources and retains the serie discriminator', () { + final serie = TitleRecord.fromJson(fixture('serie')); + expect(serie.id, 35716); + expect(serie.type, 'serie'); + expect(serie.isSeries, isTrue); + expect(serie.sources, isEmpty); + expect(TitleRecord.fromJson(serie.toJson()).type, 'serie'); + }); + test('real season retains episode/source variants and unknown metadata', () { + final raw = fixture('season'); + final season = SeasonRecord.fromJson(raw); + expect(season.id, 4263); + expect(season.episodes, hasLength(2)); + expect(season.episodes.first.id, 72819); + expect(season.episodes.first.sources.single.id, 83260); + expect(season.episodes.first.sources.single.quality, ''); + expect( + season.episodes.first.sources.single.url, + raw['episodes'][0]['sources'][0]['url'], + ); + expect(season.episodes.first.raw['playas'], '1'); + expect( + SeasonRecord.fromJson(season.toJson()).episodes.first.toJson(), + season.episodes.first.toJson(), + ); + }); + test( + 'real movie preserves source IDs, URLs, countries and entitlement metadata', + () { + final raw = fixture('movie'); + final movie = TitleRecord.fromJson(raw); + expect(movie.id, 35721); + expect(movie.type, 'movie'); + expect(movie.title, 'The Canvas'); + expect(movie.imdb, 5.2); + expect(movie.year, 2026); + expect(movie.duration, ''); + expect(movie.classification, ''); + expect(movie.genres.single.id, 1); + expect(movie.countries.single.id, 1); + expect(movie.sources.first.id, 520531); + expect(movie.sources.first.url, raw['sources'][0]['url']); + expect(movie.sources.first.isTrailer, isTrue); + expect(movie.sources[1].isTrailer, isFalse); + expect(movie.raw['playas'], '1'); + expect(movie.raw['downloadas'], '2'); + expect(movie.isSeries, isFalse); + expect(movie.isChannel, isFalse); + final restored = TitleRecord.fromJson(movie.toJson()); + expect(restored.sources[1].url, movie.sources[1].url); + expect(restored.raw['playas'], '1'); + expect( + restored.countries.single.toJson(), + movie.countries.single.toJson(), + ); + }, + ); +} diff --git a/test/native_cast_preparation_test.dart b/test/native_cast_preparation_test.dart new file mode 100644 index 0000000..80c50d7 --- /dev/null +++ b/test/native_cast_preparation_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('Cast prepares an owned resource only after an explicit request and revokes on disconnect', () async { + final prepared = Completer>(); + final events = []; + final payloads = >[]; + var preparations = 0, revocations = 0; + final session = PlayerNativeSession( + invoke: (method, args) async { + events.add(method); + if (method == 'castLoad') payloads.add(args!); + return true; + }, + local: (action, _) async => events.add(action), + media: () => {'url': '/selected/offline.mp4', 'title': 'Film'}, + prepareMedia: () { + preparations++; + return prepared.future; + }, + revokeMedia: () async { + revocations++; + }, + isPlaying: () => true, + isCurrent: () => true, + )..claim(); + addTearDown(session.release); + await session.handle(const MethodCall('castChanged', {'connected': true})); + expect(events, isEmpty); + expect(preparations, 0); + expect(await session.picker(), true); + final load = session.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + expect(preparations, 1); + expect(events, ['castShowPicker']); // No premature pause or raw file URL. + prepared.complete({ + 'url': 'http://127.0.0.1:1234/synthetic/media.mp4', + 'title': 'Film', + }); + await load; + expect(events, ['castShowPicker', 'pause', 'castLoad']); + expect(payloads.single['url'], startsWith('http://')); + expect(session.casting, true); + await session.handle(const MethodCall('castChanged', {'connected': false})); + expect(revocations, 1); + expect(session.casting, false); + }); + + test('a newer media request supersedes pending preparation without casting stale media', () async { + final first = Completer>(); + final loaded = []; + var preparations = 0; + final session = PlayerNativeSession( + castRequested: true, + invoke: (method, args) async { + if (method == 'castLoad') loaded.add(args!['url'] as String); + return true; + }, + local: (_, _) async {}, + media: () => {}, + prepareMedia: () async { + if (++preparations == 1) return first.future; + return {'url': 'https://media.test/new.mp4'}; + }, + revokeMedia: () async {}, + isPlaying: () => true, + isCurrent: () => true, + )..claim(); + addTearDown(session.release); + final old = session.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + final newer = session.loadCast(); + first.complete({'url': 'https://media.test/old.mp4'}); + await Future.wait([old, newer]); + expect(preparations, 2); + expect(loaded, ['https://media.test/new.mp4']); + }); + + test( + 'remote stop revokes sharing and connected polls do not restart it', + () async { + var live = false; + final native = []; + final session = PlayerNativeSession( + castRequested: true, + invoke: (method, _) async { + native.add(method); + return true; + }, + local: (_, _) async {}, + media: () => {}, + prepareMedia: () async { + live = true; + return {'url': 'https://media.test/film.mp4'}; + }, + revokeMedia: () async { + live = false; + }, + isPlaying: () => true, + isCurrent: () => true, + )..claim(); + addTearDown(session.release); + await session.handle( + const MethodCall('castChanged', {'connected': true}), + ); + expect(live, true); + await session.command('stop'); + expect(live, false); + expect(session.casting, false); + await session.handle( + const MethodCall('castChanged', {'connected': true}), + ); + expect(native.where((method) => method == 'castLoad'), hasLength(1)); + }, + ); +} diff --git a/test/native_player_offline_lifecycle_test.dart b/test/native_player_offline_lifecycle_test.dart new file mode 100644 index 0000000..6c2993e --- /dev/null +++ b/test/native_player_offline_lifecycle_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/media_platform.dart'; +import 'package:reelbox/media/player_page.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'cast_media_share_test.dart' show syntheticPackage, fetch; + +class _LocalNetwork extends HttpOverrides {} + +void main() { + testWidgets( + 'disposing an offline route keeps its real loopback lease until native exit', + (tester) async { + final root = await tester.runAsync( + () => Directory.systemTemp.createTemp('native-offline-route-'), + ); + final package = await tester.runAsync(() => syntheticPackage(root!)); + SharedPreferences.setMockInitialValues({}); + final app = AppState(storage: await SharedPreferences.getInstance()); + await app.initialize(discover: false); + const title = TitleRecord(id: 98, title: 'Offline fixture'); + const source = MediaSource( + url: 'https://media.test/source.m3u8', + type: 'hls', + ); + await app.recordWatch( + title, + sourceUrl: source.url, + positionSeconds: 600, + durationSeconds: 3600, + ); + final exit = Completer>(); + final opened = Completer(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method != 'openNativePlayer') return null; + opened.complete(Uri.parse((call.arguments as Map)['url'] as String)); + return exit.future; + }, + ); + addTearDown(() async { + if (!exit.isCompleted) { + exit.complete({ + 'positionMs': 0, + 'durationMs': 0, + 'opened': false, + 'completed': false, + }); + } + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ); + app.dispose(); + await tester.runAsync(() => root!.delete(recursive: true)); + }); + await tester.runAsync( + () => tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: title, + source: source, + localFile: package!.path, + ), + ), + ), + ), + ); + for (var attempt = 0; attempt < 100 && !opened.isCompleted; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(); + } + expect( + opened.isCompleted, + true, + reason: 'Offline preparation must dispatch a native open', + ); + final uri = await tester.runAsync( + () => opened.future.timeout(const Duration(seconds: 2)), + ); + expect(uri!.host, '127.0.0.1'); + final before = await tester.runAsync( + () => HttpOverrides.runWithHttpOverrides( + () => fetch(uri), + _LocalNetwork(), + ), + ); + expect(before!.status, 200); + await tester.pumpWidget(const SizedBox()); + final after = await tester.runAsync( + () => HttpOverrides.runWithHttpOverrides( + () => fetch(uri), + _LocalNetwork(), + ), + ); + expect( + after!.status, + 200, + reason: 'The native activity is still reading this offline package', + ); + exit.complete({ + 'positionMs': 1200000, + 'durationMs': 3600000, + 'opened': true, + 'completed': false, + }); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.runAsync( + () => HttpOverrides.runWithHttpOverrides(() async { + await expectLater(fetch(uri), throwsA(isA())); + }, _LocalNetwork()), + ); + expect( + app.history.single.positionSeconds, + 600, + reason: 'Disposed routes must not save stale native results', + ); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/test/native_player_route_test.dart b/test/native_player_route_test.dart new file mode 100644 index 0000000..483b93e --- /dev/null +++ b/test/native_player_route_test.dart @@ -0,0 +1,315 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/media_platform.dart'; +import 'package:reelbox/media/player_page.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const title = TitleRecord(id: 78, title: 'Film'); +const source = MediaSource( + url: 'https://media.test/film.mkv?token=unchanged', + type: 'mkv', +); +Map exitAt( + int position, { + bool opened = true, + bool completed = false, +}) => { + 'positionMs': position, + 'durationMs': 3600000, + 'opened': opened, + 'completed': completed, +}; +Future mount( + WidgetTester tester, { + EpisodeRecord? episode, + List playlist = const [], + List alternatives = const [], + String? localFile, +}) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState(storage: await SharedPreferences.getInstance()); + await state.initialize(discover: false); + await state.updateSettings(state.prefs.copyWith(autoplay: true)); + await state.recordWatch( + title, + sourceUrl: source.url, + positionSeconds: 600, + durationSeconds: 3600, + episodeId: episode?.id, + episodeTitle: episode?.title ?? '', + ); + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp( + home: PlayerPage( + title: title, + source: source, + episode: episode, + playlist: playlist, + alternatives: alternatives, + localFile: localFile, + ), + ), + ), + ); + await tester.pump(); + return state; +} + +void main() { + testWidgets( + 'early native EOF never advances or records a completed episode', + (tester) async { + const first = EpisodeRecord( + id: 101, + title: 'Episode 1', + sources: [source], + ); + const second = EpisodeRecord( + id: 102, + title: 'Episode 2', + sources: [MediaSource(url: 'https://media.test/episode2.mkv')], + ); + final calls = []; + final firstExit = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method != 'openNativePlayer') return null; + calls.add(call); + return calls.length == 1 + ? firstExit.future + : exitAt(0, opened: false); + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mount( + tester, + episode: first, + playlist: [first, second], + ); + addTearDown(state.dispose); + firstExit.complete(exitAt(180000, completed: true)); + await tester.pumpAndSettle(); + expect( + calls, + hasLength(1), + reason: 'Three minutes is not the end of this episode', + ); + expect(state.history, hasLength(1)); + expect(state.history.single.episodeId, first.id); + expect(state.history.single.positionSeconds, 180); + expect(state.history.single.durationSeconds, 3600); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'auto-next recovery keeps alternatives and history on the current episode', + (tester) async { + const oldAlternative = MediaSource( + url: 'https://media.test/episode1-720.mkv', + quality: '720p', + ); + const first = EpisodeRecord( + id: 101, + title: 'Episode 1', + sources: [source, oldAlternative], + ); + const secondSource = MediaSource(url: 'https://media.test/episode2.mkv'); + const secondAlternative = MediaSource( + url: 'https://media.test/episode2-720.mkv?token=unchanged', + quality: '720p', + ); + const trailer = MediaSource( + url: 'https://media.test/episode2-trailer.mp4', + quality: 'Trailer', + ); + const denied = MediaSource( + url: 'https://t.me/episode2', + quality: 'Denied', + ); + const second = EpisodeRecord( + id: 102, + title: 'Episode 2', + sources: [secondSource, secondAlternative, trailer, denied], + ); + final calls = []; + final firstExit = Completer>(); + final recoveryExit = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method != 'openNativePlayer') return null; + calls.add(call); + if (calls.length == 1) return firstExit.future; + if (calls.length == 2) return exitAt(0, opened: false); + return recoveryExit.future; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mount( + tester, + episode: first, + playlist: [first, second], + alternatives: first.sources, + ); + addTearDown(state.dispose); + firstExit.complete(exitAt(3600000, completed: true)); + await tester.pumpAndSettle(); + expect(calls.length, 2); + expect(calls.last.arguments['url'], secondSource.url); + expect(find.text('Episode 2'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + await tester.tap(find.widgetWithText(ListTile, '720p')); + await tester.pump(); + expect(calls.length, 3); + // Both episodes offer 720p: the row must dispatch episode 2, not 1. + final recoveryUrl = calls.last.arguments['url']; + recoveryExit.complete(exitAt(10000)); + await tester.pumpAndSettle(); + final recovered = state.history.singleWhere((e) => e.episodeId == 102); + expect(recovered.episodeTitle, second.title); + expect(recovered.positionSeconds, 10); + expect(recoveryUrl, secondAlternative.url); + expect(recovered.sourceUrl, secondAlternative.url); + expect(state.history.map((e) => e.episodeId).toSet(), {101, 102}); + expect(find.text('Trailer'), findsNothing); + expect(find.text('Denied'), findsNothing); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Android default player dispatches VLC with resume and preserves source identity', + (tester) async { + final calls = []; + final result = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + calls.add(call); + if (call.method == 'openNativePlayer') return result.future; + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mount(tester); + addTearDown(state.dispose); + await tester.pump(const Duration(milliseconds: 100)); + final call = calls.singleWhere( + (call) => call.method == 'openNativePlayer', + ); + expect(call.arguments['url'], source.url); + expect(call.arguments['positionMs'], 595000); + expect(call.arguments['autoplay'], true); + expect(state.history.single.positionSeconds, 600); + result.complete(exitAt(700000)); + await tester.pumpAndSettle(); + expect(state.history.single.positionSeconds, 700); + expect(state.history.single.sourceUrl, source.url); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets('failed native open never overwrites a saved checkpoint', ( + tester, + ) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method == 'openNativePlayer') return exitAt(0, opened: false); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mount(tester); + addTearDown(state.dispose); + await tester.pumpAndSettle(); + expect(state.history.single.positionSeconds, 600); + expect(find.text('External player'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets( + 'natural native episode completion opens next episode without losing history', + (tester) async { + const first = EpisodeRecord( + id: 101, + title: 'Episode 1', + sources: [source], + ); + const second = EpisodeRecord( + id: 102, + title: 'Episode 2', + sources: [MediaSource(url: 'https://media.test/episode2.mkv')], + ); + final calls = []; + final firstExit = Completer>(); + final secondExit = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method != 'openNativePlayer') return null; + calls.add(call); + return calls.length == 1 ? firstExit.future : secondExit.future; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mount( + tester, + episode: first, + playlist: [first, second], + ); + addTearDown(state.dispose); + await tester.pump(const Duration(milliseconds: 100)); + expect(calls.length, 1); + firstExit.complete(exitAt(3600000, completed: true)); + await tester.pump(const Duration(milliseconds: 200)); + expect(calls.length, 2); + expect(calls.last.arguments['url'], second.sources.single.url); + expect(calls.last.arguments['positionMs'], 0); + expect(state.history.single.episodeId, 101); + secondExit.complete(exitAt(10000)); + await tester.pumpAndSettle(); + expect(state.history.map((e) => e.episodeId).toSet(), {101, 102}); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/test/native_player_test.dart b/test/native_player_test.dart new file mode 100644 index 0000000..af16617 --- /dev/null +++ b/test/native_player_test.dart @@ -0,0 +1,269 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/native_player.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('org.reelbox/media'); + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('awaits exit and sends exact URL, title and checkpoint', () async { + final exit = Completer>(); + MethodCall? received; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) { + received = call; + return exit.future; + }); + var returned = false; + const url = 'https://media.example.org/a%2Fb.m3u8?sig=a%2Bz&x=1'; + final pending = + NativePlayerBridge.open( + url: url, + title: 'عنوان — episode', + position: const Duration(milliseconds: 12345), + autoplay: false, + externalPackage: 'vlc', + orientation: 'landscape', + videoFit: 'cover', + speed: 1.25, + subtitleEnabled: false, + ).then((result) { + returned = true; + return result; + }); + await Future.delayed(Duration.zero); + expect(returned, isFalse); + expect(received!.method, 'openNativePlayer'); + expect(received!.arguments, containsPair('url', url)); + expect(received!.arguments, containsPair('positionMs', 12345)); + expect(received!.arguments, containsPair('autoplay', false)); + expect(received!.arguments, containsPair('externalPackage', 'vlc')); + expect(received!.arguments, containsPair('subtitleEnabled', false)); + expect(received!.arguments, containsPair('orientation', 'landscape')); + expect(received!.arguments, containsPair('videoFit', 'cover')); + expect(received!.arguments, containsPair('speed', 1.25)); + exit.complete({ + 'positionMs': 42000, + 'durationMs': 90000, + 'opened': true, + 'completed': false, + }); + final result = await pending; + expect(result.position.inMilliseconds, 42000); + expect(result.duration.inMilliseconds, 90000); + expect(result.opened, isTrue); + expect(result.completed, isFalse); + }); + + test( + 'local and loopback sources and optional subtitles are unchanged', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return { + 'positionMs': 0, + 'durationMs': 0, + 'opened': false, + 'completed': false, + }; + }); + for (final url in [ + '/data/user/0/org.reelbox.app/files/فیلم.mkv', + 'http://127.0.0.1:49123/media/lease/index.m3u8?token=a%2Bz', + ]) { + final result = await NativePlayerBridge.open( + url: url, + title: 'Film', + subtitleUrl: 'https://media.example.org/sub.vtt?token=a%2Fz', + hardwareAcceleration: false, + pipEnabled: false, + ); + expect(calls.last.arguments, containsPair('url', url)); + expect( + calls.last.arguments, + containsPair( + 'subtitleUrl', + 'https://media.example.org/sub.vtt?token=a%2Fz', + ), + ); + expect( + calls.last.arguments, + containsPair('hardwareAcceleration', false), + ); + expect(calls.last.arguments, containsPair('pipEnabled', false)); + expect(result.opened, isFalse); + } + expect(calls, hasLength(2)); + }, + ); + + test( + 'rejects malformed exit maps instead of manufacturing progress', + () async { + for (final response in [ + null, + { + 'positionMs': -1, + 'durationMs': 10, + 'opened': true, + 'completed': false, + }, + { + 'positionMs': double.nan, + 'durationMs': 10, + 'opened': true, + 'completed': false, + }, + { + 'positionMs': 0x7fffffffffffffff, + 'durationMs': 0, + 'opened': true, + 'completed': false, + }, + { + 'positionMs': 1.5, + 'durationMs': 10, + 'opened': true, + 'completed': false, + }, + { + 'positionMs': 10, + 'durationMs': -1, + 'opened': true, + 'completed': false, + }, + { + 'positionMs': 10, + 'durationMs': 20, + 'opened': 'true', + 'completed': true, + }, + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => response); + await expectLater( + NativePlayerBridge.open( + url: 'https://media.example.org/video.mp4', + title: 'Film', + ), + throwsFormatException, + ); + } + final bounded = NativePlaybackResult.fromMap({ + 'positionMs': 1000, + 'durationMs': 100, + 'opened': false, + 'completed': true, + }); + expect(bounded.position.inMilliseconds, 100); + expect(bounded.completed, isFalse); + }, + ); + + test('completion requires a credible near-end checkpoint, not just EOF', () { + for (final sample in >[ + {'positionMs': 180000, 'durationMs': 3600000}, + {'positionMs': 180000, 'durationMs': 0}, + {'positionMs': 0, 'durationMs': 3600000}, + {'positionMs': 3600001, 'durationMs': 3600000}, + {'positionMs': 3597000, 'durationMs': 3600000}, + {'positionMs': 3600000, 'durationMs': 3600000, 'opened': false}, + {'positionMs': 3600000, 'durationMs': 3600000, 'completed': false}, + ]) { + final result = NativePlaybackResult.fromMap({ + 'opened': true, + 'completed': true, + ...sample, + }); + expect(result.completed, isFalse, reason: sample.toString()); + } + for (final position in [3599500, 3600000]) { + final result = NativePlaybackResult.fromMap({ + 'positionMs': position, + 'durationMs': 3600000, + 'opened': true, + 'completed': true, + }); + expect(result.completed, isTrue); + } + }); + + test('platform failures and busy results propagate without retry', () async { + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + calls++; + throw PlatformException(code: 'NATIVE_PLAYER_BUSY'); + }); + await expectLater( + NativePlayerBridge.open( + url: 'https://media.example.org/video.mp4', + title: 'Film', + ), + throwsA(isA()), + ); + expect(calls, 1); + }); + + test('unsupported platforms do not call Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => fail('Android was invoked'), + ); + await expectLater( + NativePlayerBridge.open( + url: 'https://media.example.org/video.mp4', + title: 'Film', + ), + throwsUnsupportedError, + ); + }); + + test('invalid launch preferences fail before starting an activity', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + fail('Invalid preferences reached Android'); + }); + await expectLater( + NativePlayerBridge.open( + url: 'https://media.example.org/video.mp4', + title: 'Film', + speed: double.nan, + ), + throwsArgumentError, + ); + await expectLater( + NativePlayerBridge.open( + url: 'https://media.example.org/video.mp4', + title: 'Film', + orientation: 'invalid', + ), + throwsArgumentError, + ); + await expectLater( + NativePlayerBridge.open( + url: 'https://media.example.org/video.mp4', + title: 'Film', + videoFit: 'invalid', + ), + throwsArgumentError, + ); + await expectLater( + NativePlayerBridge.open(url: '', title: 'Film'), + throwsArgumentError, + ); + }); +} diff --git a/test/next_catalog_discovery_test.dart b/test/next_catalog_discovery_test.dart new file mode 100644 index 0000000..2a3f0bf --- /dev/null +++ b/test/next_catalog_discovery_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/catalog_discovery.dart'; +import 'package:reelbox/data/models.dart'; + +class DiscoveryFixtureApi extends ApiService { + final calls = >[]; + dynamic response = []; + @override + Future get(String path, {Map? query}) async { + calls.add({'path': path, 'query': query}); + return response; + } + + @override + Future> bestRandom({ + bool byYear = false, + int page = 1, + }) async { + final rows = discoveryRows( + await get('fixture', query: {'action': 'best-rand-top', 'pageno': page}), + ); + return CatalogPage( + decodeTitles(rows), + nextPage: rows.isEmpty ? null : page + 1, + ); + } + + @override + Future> search( + String query, { + int page = 1, + bool english = false, + }) async { + final rows = discoveryRows( + await get( + 'fixture', + query: { + 'action': english ? 'search-movie-en' : 'search-movie', + 'q': query, + 'pageno': page, + }, + ), + ); + return CatalogPage( + decodeTitles(rows), + nextPage: rows.isEmpty ? null : page + 1, + ); + } + + final posterIds = []; + @override + Future poster(int id) async { + posterIds.add(id); + return TitleRecord(id: id, title: 'Hydrated $id'); + } + + bool actorFails = false; + @override + Future> actorTitles(int id) async { + if (actorFails) throw const ApiException('Unavailable'); + return []; + } +} + +void main() { + test( + 'thematic and franchise directories use distinct observed PHP routes', + () async { + final api = DiscoveryFixtureApi() + ..response = [ + {'id': 1, 'mazmon': 'Space'}, + ]; + final service = CatalogDiscovery(api); + final themes = await service.directory( + DiscoveryDirectory.themes, + page: 2, + ); + expect(themes.single['title'], 'Space'); + // Live public mazmon ignores pageno and returns the entire directory. + expect(themes.nextPage, isNull); + expect(api.calls.last['path'], endsWith('/api-user/filmahang/star.php')); + expect((api.calls.last['query'] as Map)['action'], 'mazmon'); + expect((api.calls.last['query'] as Map).containsKey('key'), isFalse); + await service.directory(DiscoveryDirectory.franchises, page: 3); + expect(api.calls.last['path'], endsWith('/api-user/newapi/like.php')); + expect(api.calls.last['query'], {'action': 'colection', 'pageno': 3}); + }, + ); + + test('most-shared hydrates IDs once in order and keeps raw cursor', () async { + final api = DiscoveryFixtureApi() + ..response = [ + {'id': '11'}, + {'id': '11'}, + {'id': 12}, + ]; + final result = await CatalogDiscovery(api).sharedTitles(page: 7); + expect(result.map((t) => t.id), [11, 12]); + expect(api.posterIds, [11, 12]); + expect(result.nextPage, 8); + expect((api.calls.single['query'] as Map)['action'], 'share'); + expect((api.calls.single['query'] as Map).containsKey('key'), isFalse); + }); + + test( + 'search filters apply locally without truncating raw page cursor', + () async { + final api = DiscoveryFixtureApi() + ..response = [ + {'id': 1, 'title': 'Film', 'type': 'movie', 'year': 2000}, + ]; + final result = await CatalogDiscovery(api).search( + 'query', + page: 4, + filter: const DiscoverySearchFilter(kind: 'serie', minimumYear: 2020), + ); + expect(result, isEmpty); + expect(result.nextPage, 5); + expect(api.calls.single['query'], { + 'action': 'search-movie', + 'q': 'query', + 'pageno': 4, + }); + }, + ); + + test('actor empty filmography uses paged name search rather than repeating actor request', () async { + final api = DiscoveryFixtureApi() + ..response = [ + {'id': 1, 'title': 'Name result'}, + ]; + final loader = ActorFilmography( + CatalogDiscovery(api), + id: 4, + name: 'Actor', + ); + await loader.load(1); + await loader.load(2); + expect(loader.usingNameFallback, isTrue); + expect((api.calls.last['query'] as Map)['pageno'], 2); + expect((api.calls.last['query'] as Map)['q'], 'Actor'); + }); + + test( + 'actor metadata handles nested role actor without using the role ID', + () { + final person = actorMetadata({ + 'id': 90, + 'role': 'Lead', + 'actor': { + 'id': 7, + 'name': 'Person', + 'bio': 'Biography', + 'born': '1980', + 'height': '180', + }, + }); + expect(person['id'], 7); + expect(person['name'], 'Person'); + expect(person['role'], 'Lead'); + expect(person['bio'], 'Biography'); + }, + ); + + test('continuation selects only current non-trailer source not expired history URL', () { + const sources = [ + MediaSource(url: 'https://video.example/trailer', quality: 'Trailer'), + MediaSource(id: 3, url: 'https://video.example/current', quality: '720p'), + ]; + expect( + currentHistorySource(sources, 'https://expired.example/file')?.id, + 3, + ); + expect(currentHistorySource([], 'https://expired.example/file'), isNull); + }); + + test( + 'legacy discovery retains raw continuation even for invisible rows', + () async { + final api = DiscoveryFixtureApi() + ..response = [ + {'id': 0, 'title': ''}, + ]; + final result = await CatalogDiscovery(api).suggestions(page: 4); + expect(result, isEmpty); + expect(result.nextPage, 5); + expect(api.calls.single['query'], { + 'action': 'best-rand-top', + 'pageno': 4, + }); + api.response = []; + expect( + (await CatalogDiscovery(api).suggestions(page: 5)).nextPage, + isNull, + ); + }, + ); +} diff --git a/test/next_catalog_policy_ui_test.dart b/test/next_catalog_policy_ui_test.dart new file mode 100644 index 0000000..b05ded2 --- /dev/null +++ b/test/next_catalog_policy_ui_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/media_platform.dart'; +import 'package:reelbox/ui/catalog.dart'; + +import 'next_catalog_ui_test.dart' show NextCatalogApi, mountNextCatalog; + +class PolicyCatalogApi extends NextCatalogApi { + @override + Future poster(int id) async => const TitleRecord( + id: 9, + title: 'Independent title', + sources: [ + MediaSource(url: 'https://t.me/removed_channel'), + MediaSource( + url: 'https://video.example/embed', + type: 'embed', + quality: 'Embedded video', + ), + ], + ); +} + +void main() { + testWidgets( + 'blocked title names never appear in the header and retry stays handled', + (tester) async { + await mountNextCatalog( + tester, + PolicyCatalogApi(), + const TitlePage(title: TitleRecord(id: 99, title: 'ملودی باکس')), + ); + expect(find.text('ملودی باکس'), findsNothing); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('ملودی باکس'), findsNothing); + }, + ); + testWidgets( + 'title sharing contains plain title and no original share destination', + (tester) async { + const shareChannel = MethodChannel('dev.fluttercommunity.plus/share'); + final payloads = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + shareChannel, + (call) async { + payloads.add(call.arguments as Map); + return 'success'; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + shareChannel, + null, + ), + ); + await mountNextCatalog( + tester, + NextCatalogApi(), + const TitlePage(title: TitleRecord(id: 9, title: 'Initial')), + ); + await tester.ensureVisible(find.text('Share')); + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + expect(payloads.single['text'], 'Current title'); + expect('${payloads.single}', isNot(contains('/share/'))); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets('blocked destinations do not render sources or website actions', ( + tester, + ) async { + await mountNextCatalog( + tester, + PolicyCatalogApi(), + const TitlePage(title: TitleRecord(id: 9, title: 'Initial')), + ); + expect(find.text('Website'), findsNothing); + expect(find.byTooltip('Play'), findsOneWidget); + expect(find.byTooltip('Copy source URL'), findsOneWidget); + }); + + testWidgets('embedded media uses native generic browser with video title', ( + tester, + ) async { + final payloads = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method == 'openEmbedded') payloads.add(call.arguments as Map); + return true; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + await mountNextCatalog( + tester, + PolicyCatalogApi(), + const TitlePage(title: TitleRecord(id: 9, title: 'Initial')), + ); + await tester.ensureVisible(find.byTooltip('Play')); + await tester.tap(find.byTooltip('Play')); + await tester.pumpAndSettle(); + expect(payloads.single, { + 'url': 'https://video.example/embed', + 'title': 'Independent title', + }); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('search help and advanced filters fit a small viewport', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await mountNextCatalog(tester, NextCatalogApi(), const SearchPage()); + await tester.tap(find.byTooltip('Search help')); + await tester.tap(find.text('Advanced filters')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/next_catalog_ui_test.dart b/test/next_catalog_ui_test.dart new file mode 100644 index 0000000..de0428d --- /dev/null +++ b/test/next_catalog_ui_test.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; + +import 'package:reelbox/media/media_platform.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class NextCatalogApi extends ApiService { + final pages = []; + bool series = false; + @override + Future poster(int id) async => TitleRecord( + id: id, + title: 'Current title', + type: series ? 'serie' : 'movie', + sources: const [ + MediaSource(id: 5, url: 'https://video.example/current.mp4'), + ], + ); + @override + Future> seasons(int id) async => [ + const SeasonRecord( + id: 1, + title: 'Season one', + episodes: [EpisodeRecord(id: 51, title: 'Episode one')], + ), + ]; + @override + Future>> roles(int id) async => []; + @override + Future>> comments(int id) async => []; + @override + Future>> subtitles(String kind, int id) async => []; + bool failSecond = false; + @override + Future initialize({bool discover = true}) async {} + @override + Future> home() async => { + 'genres': [], + 'channels': [ + {'id': 8, 'title': 'Removed live', 'type': 'channel'}, + ], + }; + @override + Future> genres() async => [const Tag(id: 2, title: 'Drama')]; + @override + Future> countries() async => [ + const Tag(id: 8, title: 'Country eight'), + ]; + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => page == 1 + ? [ + { + 'id': 7, + 'name': 'Real actor', + 'role': 'Lead', + 'bio': 'Actor biography', + 'born': '1980', + }, + ] + : []; + @override + Future> actorTitles(int id) async => [ + const TitleRecord(id: 72, title: 'Filmography title'), + ]; + @override + Future> catalog({ + String kind = 'movie', + int genre = 0, + int country = 0, + String order = 'created', + int page = 0, + }) async { + pages.add(page); + if (page == 1 && failSecond) { + failSecond = false; + throw const ApiException('Offline'); + } + return CatalogPage( + page == 0 + ? [] + : page == 1 + ? [const TitleRecord(id: 2, title: 'Later raw page')] + : [], + nextPage: page < 2 ? page + 1 : null, + ); + } + + @override + Future> trending() async => ['Trending term']; + @override + Future> bestRandom({ + bool byYear = false, + int page = 1, + }) async => [const TitleRecord(id: 41, title: 'Suggested real title')]; + @override + Future> search( + String query, { + int page = 1, + bool english = false, + }) async => CatalogPage( + page == 1 ? [const TitleRecord(id: 42, title: 'Query result')] : [], + nextPage: page == 1 ? 2 : null, + ); + @override + Future get(String path, {Map? query}) async => + switch (query?['action']) { + 'best-rand-top' => [ + {'id': 41, 'title': 'Suggested real title'}, + ], + 'search-movie' || 'search-movie-en' => + query?['pageno'] == 1 + ? [ + {'id': 42, 'title': 'Query result'}, + ] + : [], + _ => [], + }; +} + +Future mountNextCatalog( + WidgetTester tester, + NextCatalogApi api, + Widget page, { + bool compact = false, +}) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: api, + storage: await SharedPreferences.getInstance(), + ); + await state.initialize(discover: false); + if (compact) { + await state.updateSettings(AppSettings.fromJson({'compactCatalog': true})); + } + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp(home: page), + ), + ); + await tester.pumpAndSettle(); + return state; +} + +void main() { + testWidgets( + 'explicit episode watched marker is independent of playback progress', + (tester) async { + final api = NextCatalogApi()..series = true; + final state = await mountNextCatalog( + tester, + api, + const TitlePage( + title: TitleRecord(id: 9, title: 'Series', type: 'serie'), + ), + ); + await tester.scrollUntilVisible(find.text('Season one'), 250); + await tester.tap(find.text('Season one')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byTooltip('Mark watched')); + await tester.tap(find.byTooltip('Mark watched')); + await tester.pumpAndSettle(); + expect(state.isEpisodeWatched(9, 51), isTrue); + expect(state.history, isEmpty); + await tester.tap(find.byTooltip('Mark unwatched')); + await tester.pumpAndSettle(); + expect(state.isEpisodeWatched(9, 51), isFalse); + }, + ); + + testWidgets( + 'home continuation fetches current source instead of replaying stale history URL', + (tester) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + if (call.method == 'openExternal') { + calls.add((call.arguments as Map)['url'] as String); + } + return true; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + null, + ), + ); + final state = await mountNextCatalog( + tester, + NextCatalogApi(), + const DiscoverPage(), + ); + await state.updateSettings(state.prefs.copyWith(externalPlayer: true)); + await state.recordWatch( + const TitleRecord(id: 9, title: 'Stored history title'), + sourceUrl: 'https://expired.example/stale.mp4', + positionSeconds: 30, + durationSeconds: 100, + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Stored history title'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(find.text('Stored history title')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Stored history title')); + await tester.pumpAndSettle(); + expect(calls, ['https://video.example/current.mp4']); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'home has real actor shelf and country discovery, not live features', + (tester) async { + await mountNextCatalog(tester, NextCatalogApi(), const DiscoverPage()); + expect(find.text('Live channels'), findsNothing); + expect(find.text('Removed live'), findsNothing); + await tester.scrollUntilVisible( + find.text('Real actor'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(find.text('Real actor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Real actor')); + await tester.pumpAndSettle(); + expect(find.text('Actor biography'), findsOneWidget); + expect(find.text('Filmography title'), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Country eight'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(find.text('Country eight')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Country eight')); + await tester.pumpAndSettle(); + expect(find.byType(BrowsePage), findsOneWidget); + expect( + tester.widget(find.byType(BrowsePage)).initialCountry, + 8, + ); + }, + ); + + testWidgets( + 'empty search suggests titles and supports individual history deletion and help', + (tester) async { + final state = await mountNextCatalog( + tester, + NextCatalogApi(), + const SearchPage(), + ); + expect(find.text('Suggested real title'), findsOneWidget); + await state.rememberSearch('Keep term'); + await state.rememberSearch('Delete term'); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Remove Delete term')); + await tester.pumpAndSettle(); + expect(state.searchHistory, ['Keep term']); + expect(find.text('Search help'), findsNothing); + await tester.tap(find.byTooltip('Search help')); + await tester.pumpAndSettle(); + expect(find.textContaining('loaded search results'), findsOneWidget); + await tester.tap(find.text('Advanced filters')); + await tester.pumpAndSettle(); + expect(find.text('Minimum IMDb'), findsOneWidget); + }, + ); + + testWidgets( + 'automatic pagination follows empty raw pages and retains failed cursor', + (tester) async { + final api = NextCatalogApi()..failSecond = true; + await mountNextCatalog(tester, api, const BrowsePage(initialCountry: 8)); + expect(api.pages, [0, 1]); + expect(find.text('Try again'), findsOneWidget); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(find.text('Later raw page'), findsOneWidget); + expect(api.pages.take(3), [0, 1, 1]); + }, + ); + + testWidgets('compact catalog preference renders compact rows and count', ( + tester, + ) async { + await mountNextCatalog( + tester, + NextCatalogApi(), + const BrowsePage(), + compact: true, + ); + expect( + find.ancestor( + of: find.text('Later raw page'), + matching: find.byType(ListTile), + ), + findsOneWidget, + ); + expect(find.text('1 titles loaded'), findsOneWidget); + }); + + testWidgets( + 'directory removes public playlists and adds observed shortcuts', + (tester) async { + await mountNextCatalog( + tester, + NextCatalogApi(), + const DiscoveryDirectoryPage(), + ); + expect(find.text('Public playlists'), findsNothing); + await tester.scrollUntilVisible(find.text('Miniseries'), 250); + await tester.tap(find.text('Miniseries')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField).first).controller?.text, + 'مینی سریال', + ); + }, + ); +} diff --git a/test/next_library_cache_test.dart b/test/next_library_cache_test.dart new file mode 100644 index 0000000..5e788c8 --- /dev/null +++ b/test/next_library_cache_test.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/state/cache_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('cache size and clear are limited to disposable cache, not downloads/library/symlinks', () async { + final sandbox = await Directory('${Directory.current.path}/.dart_tool') + .createTemp('next-library-cache-'); + addTearDown(() => sandbox.delete(recursive: true)); + final cache = await Directory('${sandbox.path}/reelbox-cache').create(); + final downloads = await Directory('${sandbox.path}/downloads').create(); + final completed = await File('${downloads.path}/complete.mp4') + .writeAsBytes([1, 2, 3]); + final library = await File('${sandbox.path}/library.json') + .writeAsString('private'); + await File('${cache.path}/artwork').writeAsBytes([1, 2, 3, 4]); + await Link('${cache.path}/outside').create(downloads.path); + final service = CacheService(directory: () async => cache); + expect((await service.measure()).diskBytes, 4); + await service.clear(); + expect((await service.measure()).diskBytes, 0); + expect(await completed.readAsBytes(), [1, 2, 3]); + expect(await library.readAsString(), 'private'); + }); +} diff --git a/test/next_library_settings_test.dart b/test/next_library_settings_test.dart new file mode 100644 index 0000000..5c71583 --- /dev/null +++ b/test/next_library_settings_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/state/app_state.dart'; + +void main() { + test( + 'platform fonts resolve generic families and preserve safe custom fonts', + () { + expect( + AppSettings(subtitleFontFamily: 'serif').resolvedSubtitleFontFamily, + isNot('serif'), + ); + expect( + AppSettings(subtitleFontFamily: 'monospace').resolvedSubtitleFontFamily, + isNot('monospace'), + ); + expect( + AppSettings(subtitleFontFamily: 'My Font').resolvedSubtitleFontFamily, + 'My Font', + ); + expect(AppSettings().subtitleFontFamilyFallback, isNotEmpty); + }, + ); + test('new settings validate imports and roundtrip every field', () { + final prefs = AppSettings().copyWith( + subtitleEnabled: false, + subtitleFontFamily: 'Custom Font', + videoFit: 'cover', + audioOnly: true, + playerVolume: 0.42, + rememberVolume: true, + backgroundAudio: true, + closeOnCompletion: true, + resumeRewindSeconds: 120, + parallelDownloads: 8, + compactCatalog: true, + introCompleted: true, + externalPlayerPackage: 'org.videolan.vlc', + ); + expect(AppSettings.fromJson(prefs.toJson()).toJson(), prefs.toJson()); + final invalid = AppSettings.fromJson({ + 'playerVolume': -1, + 'parallelDownloads': 999, + 'resumeRewindSeconds': -4, + 'videoFit': 'bad', + 'subtitleEnabled': 1, + 'subtitleFontFamily': '\u0000bad', + 'externalPlayerPackage': 'https://bad.example', + 'introCompleted': false, + }); + expect(invalid.playerVolume, 1); + expect(invalid.parallelDownloads, 3); + expect(invalid.resumeRewindSeconds, 5); + expect(invalid.videoFit, 'contain'); + expect(invalid.subtitleEnabled, true); + expect(invalid.subtitleFontFamily, 'sans-serif'); + expect(invalid.externalPlayerPackage, 'ask'); + expect(invalid.introCompleted, false); + }); + test( + 'new settings expose next-release defaults, legacy JSON skips intro', + () { + final json = AppSettings().toJson(); + expect(json['subtitleEnabled'], true); + expect(json['subtitleFontFamily'], 'sans-serif'); + expect(json['videoFit'], 'contain'); + expect(json['audioOnly'], false); + expect(json['playerVolume'], 1.0); + expect(json['rememberVolume'], false); + expect(json['backgroundAudio'], false); + expect(json['closeOnCompletion'], false); + expect(json['resumeRewindSeconds'], 5); + expect(json['parallelDownloads'], 3); + expect(json['compactCatalog'], false); + expect(json['introCompleted'], false); + expect(json['externalPlayerPackage'], 'ask'); + expect(AppSettings.fromJson({}).toJson()['introCompleted'], true); + }, + ); +} diff --git a/test/next_library_state_test.dart b/test/next_library_state_test.dart new file mode 100644 index 0000000..844a84e --- /dev/null +++ b/test/next_library_state_test.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const movie = TitleRecord(id: 8, title: 'A film'); +const episode = EpisodeRecord(id: 12, title: 'Part 1'); + +class LocalApi extends ApiService { + @override + Future initialize({bool discover = true}) async {} + @override + Future poster(int id) async => const TitleRecord( + id: 8, + title: 'Current film', + image: 'https://example.org/current.jpg', + sources: [MediaSource(id: 2, url: 'https://example.org/new.mp4')], + ); + @override + Future> seasons(int id) async => const [ + SeasonRecord( + id: 1, + episodes: [ + EpisodeRecord( + id: 12, + title: 'Current episode', + sources: [ + MediaSource(id: 3, url: 'https://example.org/episode-new.mp4'), + ], + ), + ], + ), + ]; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('legacy migration filters blocked raw records before validation and persists safe state', () async { + final store = await SharedPreferences.getInstance(); + final legacy = { + 'format': 'reelbox.library', + 'version': 1, + 'settings': {'locale': 'fa'}, + 'saved': [ + movie.toJson(), + {'id': -1, 'type': 'channel'}, + {'id': 0, 'title': 'Norton'}, + ], + 'history': [], + 'searchHistory': [], + 'collections': [ + { + 'id': 'private-id', + 'name': 'Private name', + 'titles': [ + movie.toJson(), + {'title': 'ملودی باکس'}, + ], + }, + ], + }; + await store.setString(AppState.storageKey, jsonEncode(legacy)); + final state = AppState(api: LocalApi()); + await state.initialize(discover: false); + expect(state.prefs.introCompleted, true); + expect(state.saved.single.id, 8); + expect(state.collections.single.id, 'private-id'); + expect(state.collections.single.name, 'Private name'); + expect(state.collections.single.titles.single.id, 8); + final persisted = jsonDecode(store.getString(AppState.storageKey)!); + expect(persisted['saved'].length, 1); + expect(persisted['collections'][0]['titles'].length, 1); + state.dispose(); + }); + + test( + 'free-form local labels and settings cannot surface excluded promotions', + () async { + final state = AppState(api: LocalApi()); + await expectLater(state.createCollection('Norton'), throwsArgumentError); + await state.updateSettings( + state.prefs.copyWith( + subtitleFontFamily: 'ملودی باکس', + defaultQuality: 'https://t.me/example', + ), + ); + expect(state.prefs.subtitleFontFamily, 'sans-serif'); + expect(state.prefs.defaultQuality, 'ask'); + final collection = await state.createCollection('Keep me'); + await state.toggleCollectionTitle(collection.id, movie); + final backup = jsonDecode(state.exportLibrary()) as Map; + backup['collections'][0]['name'] = 'Norton'; + await state.importLibrary(jsonEncode(backup)); + expect(state.collections.single.name, 'Local collection'); + expect(state.collections.single.titles.single.id, movie.id); + state.dispose(); + }, + ); + + test('malformed backup and failed startup never reset an existing private library', () async { + final state = AppState(api: LocalApi()); + await state.toggleSaved(movie); + final before = state.exportLibrary(); + final malformed = jsonDecode(before) as Map; + malformed['watchedEpisodes'] = [ + {'title': movie.toJson(), 'episodeId': -1, 'episodeTitle': ''}, + ]; + await expectLater( + state.importLibrary(jsonEncode(malformed)), + throwsFormatException, + ); + expect(state.exportLibrary(), before); + final store = await SharedPreferences.getInstance(); + const corrupt = '{not valid JSON'; + await store.setString(AppState.storageKey, corrupt); + final restored = AppState(api: LocalApi()); + await expectLater( + restored.initialize(discover: false), + throwsFormatException, + ); + expect(store.getString(AppState.storageKey), corrupt); + state.dispose(); + restored.dispose(); + }); + + test( + 'episode watched marker persists without fabricating playback checkpoints', + () async { + final state = AppState(api: LocalApi()); + await state.setEpisodeWatched(movie, episode, true); + expect(state.isEpisodeWatched(8, 12), true); + expect(state.history, isEmpty); + await state.recordWatch(movie, episodeId: 12, positionSeconds: 24); + await state.setEpisodeWatched(movie, episode, false); + expect(state.isEpisodeWatched(8, 12), false); + expect(state.resumeSeconds(8, episodeId: 12), 24); + await state.setEpisodeWatched(movie, episode, true); + final restored = AppState(api: LocalApi()); + await restored.initialize(discover: false); + expect(restored.isEpisodeWatched(8, 12), true); + final imported = AppState(api: LocalApi()); + await imported.importLibrary(state.exportLibrary()); + expect(imported.isEpisodeWatched(8, 12), true); + state.dispose(); + restored.dispose(); + imported.dispose(); + }, + ); + + test( + 'subtitle reset changes only subtitle fields and preserves local library', + () async { + final state = AppState(api: LocalApi()); + await state.toggleSaved(movie); + await state.createCollection('Private'); + await state.updateSettings( + state.prefs.copyWith( + locale: 'fa', + parallelDownloads: 7, + subtitleFontSize: 60, + subtitleColor: 0x12123456, + subtitleDelay: 4, + subtitleFontFamily: 'serif', + subtitleEnabled: false, + audioDelay: 2, + ), + ); + await state.resetSubtitleStyle(); + expect(state.prefs.subtitleFontSize, 24); + expect(state.prefs.subtitleColor, 0xFFFFFFFF); + expect(state.prefs.subtitleFontFamily, 'sans-serif'); + expect(state.prefs.subtitleDelay, 0); + expect(state.prefs.subtitleEnabled, true); + expect(state.prefs.audioDelay, 2); + expect(state.prefs.parallelDownloads, 7); + expect(state.locale, 'fa'); + expect(state.saved.single.id, 8); + expect(state.collections.single.name, 'Private'); + state.dispose(); + }, + ); + + test('missing current episode rejects continuation instead of playing an expired checkpoint URL', () async { + final state = AppState(api: LocalApi()); + await state.recordWatch( + movie, + episodeId: 404, + sourceUrl: 'https://example.org/expired.mp4', + positionSeconds: 99, + ); + await expectLater( + state.resolveHistory(state.history.single), + throwsStateError, + ); + expect(state.resumeSeconds(8, episodeId: 404), 99); + state.dispose(); + }); + + test( + 'continuation resolves fresh poster and episode sources, never stale URL', + () async { + final state = AppState(api: LocalApi()); + await state.recordWatch( + movie, + episodeId: 12, + sourceUrl: 'https://example.org/expired.mp4', + positionSeconds: 24, + ); + final continuation = await state.resolveHistory(state.history.single); + expect(continuation.title.image, 'https://example.org/current.jpg'); + expect(continuation.source.url, 'https://example.org/episode-new.mp4'); + expect(continuation.episode?.id, 12); + expect(continuation.playlist.single.id, 12); + state.dispose(); + }, + ); + + test('legacy import retains private collections while removing blocked titles/channels', () async { + final state = AppState(api: LocalApi()); + await state.toggleSaved(movie); + final collection = await state.createCollection('Private'); + await state.toggleCollectionTitle(collection.id, movie); + final backup = jsonDecode(state.exportLibrary()) as Map; + backup['saved'].add( + const TitleRecord(id: 9, type: 'channel', title: 'Channel').toJson(), + ); + backup['saved'].add( + const TitleRecord(id: 10, title: 'ملودی باکس').toJson(), + ); + backup['collections'][0]['titles'].add( + const TitleRecord(id: 11, title: 'Norton').toJson(), + ); + await state.importLibrary(jsonEncode(backup)); + expect(state.saved.map((t) => t.id), [8]); + expect(state.collections.single.titles.map((t) => t.id), [8]); + await state.toggleSaved(const TitleRecord(id: 10, title: 'ملودی باکس')); + expect(state.saved.length, 1); + state.dispose(); + }); +} diff --git a/test/next_library_ui_test.dart b/test/next_library_ui_test.dart new file mode 100644 index 0000000..9742347 --- /dev/null +++ b/test/next_library_ui_test.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/library.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _LocalApi extends ApiService { + @override + Future initialize({bool discover = true}) async {} + @override + Future> home() async => { + 'genres': [], + 'actors': [], + 'countries': [], + }; + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => []; + @override + Future> countries() async => []; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + Future state() async { + final s = AppState(api: _LocalApi()); + await s.initialize(discover: false); + addTearDown(s.dispose); + return s; + } + + Widget host(AppState s, Widget child) => AppScope( + state: s, + child: MaterialApp(home: child), + ); + + testWidgets( + 'fresh app routes through introduction before exposing navigation', + (tester) async { + final s = await state(); + expect(s.prefs.introCompleted, false); + await tester.pumpWidget(MyApp(state: s)); + await tester.pumpAndSettle(); + expect(find.byType(FirstRunIntroPage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + await tester.tap(find.byKey(const Key('intro-skip'))); + await tester.pumpAndSettle(); + expect(s.prefs.introCompleted, true); + expect(find.byType(FirstRunIntroPage), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }, + ); + + testWidgets( + 'counted library tabs remain readable and reachable with large text', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final s = await state(); + await tester.pumpWidget( + host( + s, + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: const LibraryPage(), + ), + ), + ); + await tester.pumpAndSettle(); + final label = find.text('Collections (0)'); + await tester.ensureVisible(label); + await tester.pumpAndSettle(); + final render = tester.renderObject(label); + expect( + render.size.width, + greaterThanOrEqualTo( + render.getMaxIntrinsicWidth(double.infinity) - 0.1, + ), + ); + await tester.tap(label); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + for (final locale in ['en', 'fa']) { + testWidgets( + 'More shares independent app information in $locale without an invented install link', + (tester) async { + const channel = MethodChannel('dev.fluttercommunity.plus/share'); + final payloads = []; + final methods = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + methods.add(call.method); + payloads.add(call.arguments as Map); + return 'fixture.receiver'; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final s = await state(); + await s.updateSettings(s.prefs.copyWith(locale: locale)); + await tester.pumpWidget(host(s, const MorePage())); + expect(payloads, isEmpty); + final action = find.byKey(const Key('share-reelbox')); + await tester.scrollUntilVisible(action, 300); + await tester.ensureVisible(action); + await tester.pumpAndSettle(); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(payloads, hasLength(1)); + expect(methods, ['share']); + expect( + payloads.single['text'], + locale == 'en' + ? 'Reelbox — an account-free film and series client with a private, device-local library' + : 'Reelbox — فیلم و سریال، با کتابخانه خصوصی روی دستگاه و بدون نیاز به حساب کاربری', + ); + expect(payloads.single['title'], 'Reelbox'); + expect( + payloads.single['text'], + isNot( + matches( + RegExp( + r'https?://|telegram|t\.me/|ملودی|نورتون', + caseSensitive: false, + ), + ), + ), + ); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + } + + testWidgets( + 'unavailable app sharing shows a safe error rather than native details', + (tester) async { + const channel = MethodChannel('dev.fluttercommunity.plus/share'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + _, + ) async { + throw PlatformException( + code: 'fixture', + message: 'private native fixture detail', + ); + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final s = await state(); + await tester.pumpWidget(host(s, const MorePage())); + final action = find.byKey(const Key('share-reelbox')); + await tester.scrollUntilVisible(action, 300); + await tester.ensureVisible(action); + await tester.pumpAndSettle(); + await tester.tap(action); + await tester.pumpAndSettle(); + expect( + find.text('Sharing is unavailable on this device'), + findsOneWidget, + ); + expect( + find.textContaining('private native fixture detail'), + findsNothing, + ); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets('first-run skip persists completion without touching library', ( + tester, + ) async { + final s = await state(); + await tester.pumpWidget(host(s, const FirstRunIntroPage())); + await tester.tap(find.byKey(const Key('intro-skip'))); + await tester.pumpAndSettle(); + expect(s.prefs.introCompleted, true); + }); + testWidgets('first-run finish persists completion', (tester) async { + final s = await state(); + await tester.pumpWidget(host(s, const FirstRunIntroPage())); + await tester.tap(find.byKey(const Key('intro-complete'))); + await tester.pumpAndSettle(); + expect(s.prefs.introCompleted, true); + }); + testWidgets( + 'introduction keeps a short description and local-data disclosure', + (tester) async { + final s = await state(); + await tester.pumpWidget(host(s, const FirstRunIntroPage())); + expect( + find.text( + 'Save films and series, resume watching and download for offline viewing.', + ), + findsOneWidget, + ); + expect(find.textContaining('No account needed'), findsOneWidget); + expect( + find.textContaining('external catalog and media providers'), + findsOneWidget, + ); + expect(find.text('English'), findsOneWidget); + expect(find.text('فارسی'), findsOneWidget); + }, + ); + + testWidgets( + 'more exposes no removed service links and about uses runtime package info', + (tester) async { + final s = await state(); + PackageInfo.setMockInitialValues( + appName: 'Reelbox', + packageName: 'org.reelbox.app', + version: '8.9.10', + buildNumber: '456', + buildSignature: '', + ); + await tester.pumpWidget(host(s, const MorePage())); + expect(find.text('Live channels'), findsNothing); + expect(find.text('Public playlists'), findsNothing); + expect(find.text('Original service support'), findsNothing); + await tester.scrollUntilVisible(find.text('About Reelbox'), 300); + await tester.tap(find.text('About Reelbox')); + await tester.pumpAndSettle(); + expect(find.textContaining('8.9.10'), findsOneWidget); + expect(find.textContaining('456'), findsOneWidget); + expect(find.textContaining('org.reelbox.app'), findsOneWidget); + for (final label in [ + 'Dart runtime', + 'Playback engine', + 'Display', + 'Device platform', + 'Flutter framework', + 'Flutter engine revision', + ]) { + expect(find.text(label), findsNothing); + } + expect(find.text('Open-source licenses'), findsOneWidget); + await tester.ensureVisible(find.text('Open-source licenses')); + await tester.tap(find.text('Open-source licenses')); + await tester.pumpAndSettle(); + expect(find.byType(LicensePage), findsOneWidget); + }, + ); + testWidgets( + 'custom ARGB color and opacity save exact RGB without changing background', + (tester) async { + final s = await state(); + await tester.pumpWidget(host(s, const SettingsPage())); + await tester.scrollUntilVisible(find.text('Custom subtitle color'), 450); + await tester.tap(find.text('Custom subtitle color')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('custom-color-hex')), + '12345678', + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('custom-color-opacity'))); + await tester.pump(); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Save'), + ), + ); + await tester.pumpAndSettle(); + expect(s.prefs.subtitleColor & 0xFFFFFF, 0x345678); + expect(s.prefs.subtitleColor >> 24, inInclusiveRange(1, 254)); + expect(s.prefs.subtitleBackground, 0x80000000); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'settings safely displays imported custom values and resets subtitle style only', + (tester) async { + final s = await state(); + await s.updateSettings( + s.prefs.copyWith( + subtitleFontFamily: 'My Custom Font', + subtitleColor: 0x12123456, + subtitleFontSize: 72, + playbackSpeed: 1.7, + parallelDownloads: 7, + playerVolume: 0.33, + ), + ); + await tester.pumpWidget(host(s, const SettingsPage())); + await tester.scrollUntilVisible( + find.byKey(const Key('reset-subtitle-style')), + 450, + ); + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const Key('reset-subtitle-style'))); + await tester.pumpAndSettle(); + expect(s.prefs.subtitleColor, 0xFFFFFFFF); + expect(s.prefs.parallelDownloads, 7); + expect(s.prefs.playbackSpeed, 1.7); + }, + ); +} diff --git a/test/playback_checkpoint_test.dart b/test/playback_checkpoint_test.dart new file mode 100644 index 0000000..de0d34e --- /dev/null +++ b/test/playback_checkpoint_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; + +void main() { + test('opening a player cannot overwrite an existing resume checkpoint', () { + final playback = PlaybackCheckpoint(); + const savedPosition = Duration(minutes: 17); + var storedPosition = savedPosition; + void persist() { + if (playback.canPersist) storedPosition = playback.position; + } + + persist(); + playback.beginOpen(); + persist(); + expect(storedPosition, savedPosition); + expect(playback.canPersist, isFalse); + // Failed opens, duration discovery and a zero position are not watched time. + playback.duration = const Duration(hours: 2); + playback.position = Duration.zero; + persist(); + expect(storedPosition, savedPosition); + playback.didOpen(); + persist(); + expect(storedPosition, savedPosition); + playback.position = const Duration(minutes: 18); + persist(); + expect(storedPosition, const Duration(minutes: 18)); + }); + test( + 'source switches do not attribute previous progress to the new source', + () { + final playback = PlaybackCheckpoint(); + playback.beginOpen(); + playback.didOpen(); + playback.position = const Duration(minutes: 3); + expect(playback.canPersist, isTrue); + playback.beginOpen(); + expect(playback.position, Duration.zero); + expect(playback.canPersist, isFalse); + playback.position = const Duration(seconds: 40); + expect(playback.canPersist, isFalse); + playback.didOpen(); + expect(playback.canPersist, isTrue); + }, + ); +} diff --git a/test/playback_dispatch_preference_test.dart b/test/playback_dispatch_preference_test.dart new file mode 100644 index 0000000..de4b57f --- /dev/null +++ b/test/playback_dispatch_preference_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:reelbox/media/media_helpers.dart'; + +void main() { + for (final autoplay in [true, false]) { + test( + 'engine open dispatch uses autoplay=$autoplay for remote and offline media', + () async { + for (final uri in ['https://example.test/a.mp4', '/offline/a.mp4']) { + final calls = <({Media media, bool play})>[]; + await dispatchMediaOpen( + open: (media, {play = true}) async => + calls.add((media: media, play: play)), + uri: uri, + start: const Duration(seconds: 20), + autoplay: autoplay, + ); + expect(calls.single.play, autoplay); + expect(calls.single.media.uri, uri); + expect(calls.single.media.start, const Duration(seconds: 20)); + } + }, + ); + } +} diff --git a/test/player_brightness_handoff_test.dart b/test/player_brightness_handoff_test.dart new file mode 100644 index 0000000..6da797c --- /dev/null +++ b/test/player_brightness_handoff_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; +import 'package:reelbox/media/media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('player handoff retains pre-player brightness rather than an earlier override', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, null); + }); + var windowBrightness = -1.0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, (call) async { + switch (call.method) { + case 'getBrightness': + return windowBrightness; + case 'getEffectiveBrightness': + return .55; + case 'getVolume': + return .5; + case 'setBrightness': + windowBrightness = (call.arguments as Map)['value'] as double; + return null; + } + return null; + }); + final old = PlaybackLifecycle(), current = PlaybackLifecycle(); + final oldControls = PlayerSystemControls(), + currentControls = PlayerSystemControls(); + await old.setup([oldControls.capture]); + await MediaPlatform.invoke('setBrightness', {'value': .8}); + await current.setup([currentControls.capture]); + expect(currentControls.brightness, .8); + await old.dispose(oldControls.restore); + expect( + windowBrightness, + .8, + reason: 'Old teardown must not disturb the current owner', + ); + await current.dispose(currentControls.restore); + expect( + windowBrightness, + -1.0, + reason: 'Last owner must restore the original activity baseline', + ); + }); +} diff --git a/test/player_controls_next_test.dart b/test/player_controls_next_test.dart new file mode 100644 index 0000000..72da447 --- /dev/null +++ b/test/player_controls_next_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; +import 'package:reelbox/media/player_page.dart'; + +void main() { + test( + 'resume rewinds but completed progress resets without negative positions', + () { + expect( + resumePosition( + position: const Duration(seconds: 60), + duration: const Duration(seconds: 600), + rewindSeconds: 5, + ), + const Duration(seconds: 55), + ); + expect( + resumePosition( + position: const Duration(seconds: 6), + duration: const Duration(seconds: 600), + rewindSeconds: 120, + ), + Duration.zero, + ); + expect( + resumePosition( + position: const Duration(seconds: 590), + duration: const Duration(seconds: 600), + rewindSeconds: 5, + ), + Duration.zero, + ); + }, + ); + test( + 'grouped subtitle variants inherit language and reject unsafe sources', + () { + final groups = subtitleGroups([ + { + 'language': 'Persian', + 'subtitles': [ + {'title': 'WEB-DL', 'url': 'https://media.test/fa.srt'}, + {'name': 'BluRay', 'link': 'https://media.test/fa2.srt'}, + {'url': 'javascript:bad'}, + ], + }, + { + 'language': {'name': 'English'}, + 'title': 'SDH', + 'url': 'https://media.test/en.vtt', + }, + ]); + expect(groups.keys, ['Persian', 'English']); + expect(groups['Persian']!.map((e) => e.label), ['WEB-DL', 'BluRay']); + expect(groups['English']!.single.url, 'https://media.test/en.vtt'); + }, + ); + testWidgets( + 'keyboard media shortcuts dispatch commands; locked rejects input', + (tester) async { + final actions = []; + Future mount(bool locked) => tester.pumpWidget( + MaterialApp( + home: PlayerInteraction( + locked: locked, + controlsVisible: true, + onAction: actions.add, + child: const SizedBox.expand(), + ), + ), + ); + await mount(false); + await tester.pump(); + for (final key in [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyF, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.mediaTrackNext, + LogicalKeyboardKey.keyS, + ]) { + await tester.sendKeyEvent(key); + } + expect(actions, [ + 'toggle', + 'mute', + 'fullscreen', + 'back', + 'next', + 'subtitles', + ]); + await mount(true); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(actions.length, 6); + }, + ); + testWidgets('double click requests fullscreen rather than changing fit', ( + tester, + ) async { + final actions = []; + await tester.pumpWidget( + MaterialApp( + home: PlayerInteraction( + locked: false, + controlsVisible: true, + onAction: actions.add, + child: PlayerSurfaceGestures(locked: false, onAction: actions.add), + ), + ), + ); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 350)); + expect(actions, contains('fullscreen')); + expect(actions, isNot(contains('fit'))); + }); +} diff --git a/test/player_engine_dispatch_next_test.dart b/test/player_engine_dispatch_next_test.dart new file mode 100644 index 0000000..efc28c6 --- /dev/null +++ b/test/player_engine_dispatch_next_test.dart @@ -0,0 +1,670 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/player_page.dart'; +import 'package:reelbox/media/media_platform.dart'; +import 'package:reelbox/media/cast_media_share.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Streams implements PlayerStream { + final positions = StreamController.broadcast(); + final durations = StreamController.broadcast(); + final playingEvents = StreamController.broadcast(); + final completedEvents = StreamController.broadcast(); + @override + Stream get position => positions.stream; + @override + Stream get duration => durations.stream; + @override + Stream get playing => playingEvents.stream; + @override + Stream get completed => completedEvents.stream; + @override + Stream get buffering => const Stream.empty(); + @override + Stream get tracks => const Stream.empty(); + @override + Stream get error => const Stream.empty(); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + Future close() async { + await positions.close(); + await durations.close(); + await playingEvents.close(); + await completedEvents.close(); + } +} + +class _Engine implements Player { + @override + final _Streams stream = _Streams(); + @override + PlayerState get state => + PlayerState(tracks: Tracks(subtitle: subtitleTracks)); + List subtitleTracks = const [ + SubtitleTrack('auto', null, null), + SubtitleTrack('no', null, null), + ]; + final calls = []; + final opens = []; + @override + PlatformPlayer? platform; + @override + Future open(Playable playable, {bool play = true}) async { + final m = playable as Media; + opens.add(m); + calls.add('open:$play'); + } + + @override + Future play() async { + calls.add('play'); + stream.playingEvents.add(true); + } + + @override + Future pause() async { + calls.add('pause'); + stream.playingEvents.add(false); + } + + @override + Future seek(Duration position) async { + calls.add('seek:${position.inSeconds}'); + stream.positions.add(position); + } + + @override + Future setRate(double value) async { + calls.add('rate:$value'); + } + + @override + Future setVolume(double value) async { + calls.add('volume:$value'); + } + + @override + Future setVideoTrack(VideoTrack value) async { + calls.add('video:${value.id}'); + } + + @override + Future setSubtitleTrack(SubtitleTrack value) async { + calls.add('subtitle:${value.id}'); + } + + @override + Future dispose() async { + calls.add('dispose'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + testWidgets( + 'persisted Stretch dispatches actual fill geometry instead of contain', + (tester) async { + final app = AppState(); + final engine = _Engine(); + final fits = []; + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await engine.stream.close(); + app.dispose(); + }); + await app.updateSettings(AppSettings(videoFit: 'fill')); + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: const TitleRecord(id: 82, title: 'Film'), + source: const MediaSource(url: 'https://media.test/film.mp4'), + playerFactory: () => engine, + videoControllerFactory: (_) => null, + videoSurfaceBuilder: (fit) { + fits.add(fit); + return const SizedBox.expand(); + }, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(fits.last, BoxFit.fill); + await tester.tap(find.byTooltip('Fit or fill')); + await tester.pump(); + expect(fits.last, BoxFit.cover); + await tester.tap(find.byTooltip('Fit or fill')); + await tester.pump(); + expect(fits.last, BoxFit.contain); + }, + ); + testWidgets( + 'actual subtitle track picker updates active Cast text and clears stale external data', + (tester) async { + final app = AppState(); + final engine = _Engine(); + const aText = '1\n00:00:00,000 --> 00:00:01,000\nExternal A'; + const bText = '1\n00:00:00,000 --> 00:00:01,000\nExternal B'; + engine.subtitleTracks = [ + SubtitleTrack.data(aText, title: 'External A'), + const SubtitleTrack('2', 'Embedded B', 'en'), + SubtitleTrack.no(), + SubtitleTrack.data(bText, title: 'External B'), + ]; + final sharedText = []; + var closed = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async => switch (call.method) { + 'getBrightness' => -1.0, + 'getEffectiveBrightness' => 0.6, + 'getVolume' => 0.5, + 'castGetState' => { + 'connected': true, + 'deviceName': 'Fixture receiver', + }, + 'castLoad' => true, + _ => null, + }, + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await engine.stream.close(); + app.dispose(); + }); + await app.updateSettings(AppSettings()); + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: const TitleRecord(id: 81, title: 'Film'), + source: const MediaSource(url: 'https://media.test/film.mp4'), + playerFactory: () => engine, + videoControllerFactory: (_) => null, + castShareFactory: + ({required payload, localFile, subtitleText}) async { + sharedText.add(subtitleText); + return CastMediaLease( + payload: payload, + onClose: () async { + closed++; + }, + ); + }, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + Future choose(String name) async { + await tester.tap(find.byTooltip('Player settings')); + await tester.pumpAndSettle(); + final track = engine.subtitleTracks.firstWhere( + (track) => (track.title ?? track.language ?? track.id) == name, + ); + final target = find.byKey(ValueKey(track)); + await tester.scrollUntilVisible( + target, + 300, + scrollable: find + .descendant( + of: find.byType(BottomSheet), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.ensureVisible(target); + await tester.pumpAndSettle(); + await tester.tap(target); + await tester.pumpAndSettle(); + } + + await choose('External A'); + await tester.tap(find.byTooltip('Cast to device')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cast this video')); + await tester.pumpAndSettle(); + expect(sharedText, [aText]); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + await choose('Embedded B'); + expect(sharedText, [aText, null]); + expect( + find.text( + 'Embedded subtitles cannot be shared; Cast continues without them', + ), + findsOneWidget, + ); + await choose('no'); + expect(app.prefs.subtitleEnabled, false); + expect(sharedText, [aText, null, null]); + await choose('External B'); + expect(sharedText.last, bText); + expect(app.prefs.subtitleEnabled, true); + expect(closed, 3); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + testWidgets( + 'offline player explicitly hands Cast a sharing lease and revokes it on exit', + (tester) async { + final app = AppState(); + final engine = _Engine(); + var prepared = 0, closed = 0; + String? preparedFile; + final loads = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + return switch (call.method) { + 'getBrightness' => -1.0, + 'getEffectiveBrightness' => 0.6, + 'getVolume' => 0.5, + 'castGetState' => { + 'connected': true, + 'deviceName': 'Synthetic receiver', + }, + 'castLoad' => (() { + loads.add(call.arguments as Map); + return true; + })(), + 'castShowPicker' => true, + _ => null, + }; + }, + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await engine.stream.close(); + app.dispose(); + }); + await app.updateSettings(AppSettings()); + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: const TitleRecord(id: 42, title: 'Synthetic offline film'), + source: const MediaSource(url: 'https://media.test/original.mp4'), + localFile: '/selected/synthetic.mp4', + playerFactory: () => engine, + videoControllerFactory: (_) => null, + castShareFactory: + ({required payload, localFile, subtitleText}) async { + prepared++; + preparedFile = localFile; + return CastMediaLease( + payload: { + ...payload, + 'url': 'http://127.0.0.1:1234/synthetic/media.mp4', + }, + onClose: () async { + closed++; + }, + ); + }, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect( + prepared, + 0, + reason: 'Opening a page must not start local sharing', + ); + await tester.tap(find.byTooltip('Cast to device')); + await tester.pumpAndSettle(); + expect( + prepared, + 0, + reason: 'Choosing a receiver menu must not start sharing', + ); + await tester.tap(find.text('Cast this video')); + await tester.pump(const Duration(milliseconds: 100)); + // Native receiver connection callback, not a fabricated successful load. + final completed = Completer(); + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + MediaPlatform.channel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('castChanged', { + 'connected': true, + 'deviceName': 'Synthetic receiver', + }), + ), + (_) => completed.complete(), + ); + await tester.pump(); + await completed.future; + await tester.pump(); + expect(prepared, 1); + expect(preparedFile, '/selected/synthetic.mp4'); + expect(Uri.parse(loads.single['url'] as String).scheme, 'http'); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + expect(closed, 1); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + for (final change in [ + 'quality', + 'episode', + 'subtitles', + 'stop', + 'new owner', + ]) { + testWidgets( + 'pending page Cast lease is invalidated by actual $change change', + (tester) async { + final app = AppState(); + final engine = _Engine(); + final newerEngine = _Engine(); + final prepared = Completer(); + final loads = []; + final snapshots = >[]; + var closed = 0; + const a = MediaSource(url: 'https://media.test/a.mp4', quality: '720p'); + const b = MediaSource( + url: 'https://media.test/b.mp4', + quality: '1080p', + ); + const one = EpisodeRecord(id: 1, title: 'First', sources: [a, b]); + const two = EpisodeRecord(id: 2, title: 'Second', sources: [b]); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + MediaPlatform.channel, + (call) async { + return switch (call.method) { + 'getBrightness' => -1.0, + 'getEffectiveBrightness' => 0.6, + 'getVolume' => 0.5, + 'castGetState' => {'connected': true, 'deviceName': 'Receiver'}, + 'castLoad' => (() { + loads.add(call.arguments as Map); + return true; + })(), + _ => null, + }; + }, + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await engine.stream.close(); + await newerEngine.stream.close(); + app.dispose(); + }); + await app.updateSettings(AppSettings()); + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: const TitleRecord(id: 43, title: 'Series'), + source: a, + episode: one, + playlist: const [one, two], + playerFactory: () => engine, + videoControllerFactory: (_) => null, + castShareFactory: + ({required payload, localFile, subtitleText}) async { + snapshots.add({...payload, 'selectedText': subtitleText}); + if (snapshots.length == 1) return prepared.future; + return CastMediaLease( + payload: payload, + onClose: () async {}, + ); + }, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(snapshots, isEmpty); + await tester.tap(find.byTooltip('Cast to device')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cast this video')); + await tester.pump(); + expect(snapshots.single['url'], a.url); + // Dismiss the actual sheet while its explicit load is still pending. + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + if (change == 'quality') { + await tester.tap(find.text('1080p')); + } else if (change == 'episode') { + await tester.tap(find.byTooltip('Next episode')); + } else if (change == 'subtitles') { + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + } else if (change == 'stop') { + await tester.sendKeyEvent(LogicalKeyboardKey.mediaStop); + } else { + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + key: const ValueKey('new owner'), + title: const TitleRecord(id: 44, title: 'New owner'), + source: b, + playerFactory: () => newerEngine, + videoControllerFactory: (_) => null, + ), + ), + ), + ); + } + await tester.pump(const Duration(milliseconds: 100)); + if (change == 'quality' || change == 'episode') { + expect(engine.opens.last.uri, b.url); + } + if (change == 'subtitles') expect(app.prefs.subtitleEnabled, false); + prepared.complete( + CastMediaLease( + payload: {'url': a.url, 'title': 'Obsolete'}, + onClose: () async { + closed++; + }, + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect( + loads, + isEmpty, + reason: + 'A malicious late lease must not load its captured old payload', + ); + expect(closed, 1); + expect(find.textContaining('Could not load this video'), findsNothing); + if (change == 'quality' || + change == 'episode' || + change == 'subtitles') { + // Cancellation does not silently re-share; another explicit action can. + expect(snapshots, hasLength(1)); + await tester.tap(find.byTooltip('Cast to device')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cast this video')); + await tester.pumpAndSettle(); + expect(loads.single['url'], change == 'subtitles' ? a.url : b.url); + expect(snapshots, hasLength(2)); + } + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + } + setUp(() { + SharedPreferences.setMockInitialValues({}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, (call) async { + return switch (call.method) { + 'getBrightness' => -1.0, + 'getEffectiveBrightness' => 0.6, + 'getVolume' => 0.5, + 'castGetState' => {'connected': false}, + _ => null, + }; + }); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + testWidgets( + 'real player controls dispatch to engine and persist fit/subtitle options', + (tester) async { + final app = AppState(); + final engine = _Engine(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await engine.stream.close(); + app.dispose(); + }); + await app.updateSettings(AppSettings(playbackSpeed: 1.75)); + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: const TitleRecord(id: 1, title: 'Film'), + source: const MediaSource(url: 'https://media.test/film.mp4'), + playerFactory: () => engine, + videoControllerFactory: (_) => null, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + // The native decoder is replaced, not the actual player control dispatch. + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: + 'engine calls: ${engine.calls}; text: ${tester.widgetList(find.byType(Text)).map((e) => e.data).toList()}', + ); + expect(engine.calls, containsAllInOrder(['open:false', 'rate:1.75'])); + engine.stream.positions.add(const Duration(seconds: 30)); + engine.stream.durations.add(const Duration(seconds: 600)); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM); + await tester.pump(); + expect(engine.calls, contains('seek:40')); + expect(engine.calls, contains('volume:0.0')); + await tester.tap(find.byTooltip('Fit or fill')); + await tester.pump(const Duration(milliseconds: 100)); + // The native decoder is replaced, not the actual player control dispatch. + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: + 'engine calls: ${engine.calls}; text: ${tester.widgetList(find.byType(Text)).map((e) => e.data).toList()}', + ); + expect(app.prefs.videoFit, 'cover'); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.pump(const Duration(milliseconds: 100)); + // The native decoder is replaced, not the actual player control dispatch. + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: + 'engine calls: ${engine.calls}; text: ${tester.widgetList(find.byType(Text)).map((e) => e.data).toList()}', + ); + expect(app.prefs.subtitleEnabled, false); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + expect(find.byTooltip('Player settings'), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(engine.calls, containsAllInOrder(['play', 'pause'])); + expect(find.byTooltip('Player settings'), findsOneWidget); + }, + ); + testWidgets( + 'resume retains history, rewinds dispatch, and next updates episode context', + (tester) async { + final app = AppState(); + final engine = _Engine(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await engine.stream.close(); + app.dispose(); + }); + const title = TitleRecord(id: 2, title: 'Series'); + const one = EpisodeRecord( + id: 11, + title: 'Episode one', + sources: [MediaSource(url: 'https://media.test/1.mp4')], + ); + const two = EpisodeRecord( + id: 12, + title: 'Episode two', + sources: [MediaSource(url: 'https://media.test/2.mp4')], + ); + await app.updateSettings(AppSettings(resumeRewindSeconds: 5)); + await app.recordWatch( + title, + episodeId: 11, + episodeTitle: one.title, + positionSeconds: 60, + durationSeconds: 600, + sourceUrl: one.sources.single.url, + ); + await tester.pumpWidget( + AppScope( + state: app, + child: MaterialApp( + home: PlayerPage( + title: title, + source: one.sources.single, + episode: one, + playlist: const [one, two], + playerFactory: () => engine, + videoControllerFactory: (_) => null, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + // The native decoder is replaced, not the actual player control dispatch. + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: + 'engine calls: ${engine.calls}; text: ${tester.widgetList(find.byType(Text)).map((e) => e.data).toList()}', + ); + expect(engine.opens.single.start, const Duration(seconds: 55)); + expect(app.history.single.positionSeconds, 60); + engine.stream.completedEvents.add(true); + await tester.pump(const Duration(milliseconds: 100)); + // The native decoder is replaced, not the actual player control dispatch. + expect( + find.byType(CircularProgressIndicator), + findsNothing, + reason: + 'engine calls: ${engine.calls}; text: ${tester.widgetList(find.byType(Text)).map((e) => e.data).toList()}', + ); + expect(engine.opens.last.uri, two.sources.single.url); + expect(find.text('Episode two'), findsOneWidget); + expect(app.history.single.positionSeconds, 60); + }, + ); +} diff --git a/test/player_global_ownership_test.dart b/test/player_global_ownership_test.dart new file mode 100644 index 0000000..67dce9e --- /dev/null +++ b/test/player_global_ownership_test.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; + +void main() { + for (final disposeFirst in [true, false]) { + test( + 'old player teardown cannot overwrite new owner (dispose first=$disposeFirst)', + () async { + final old = PlaybackLifecycle(); + final current = PlaybackLifecycle(); + final pending = Completer(); + final writes = []; + final oldSetup = old.setup([ + () async { + writes.add('old-active'); + await pending.future; + }, + ]); + await Future.delayed(Duration.zero); + late Future teardown; + late Future newSetup; + Future restoreOld() async { + writes.add('old-restore'); + } + + Future setupNew() async { + writes.add('new-active'); + } + + if (disposeFirst) { + teardown = old.dispose(restoreOld); + newSetup = current.setup([setupNew]); + } else { + newSetup = current.setup([setupNew]); + teardown = old.dispose(restoreOld); + } + await Future.delayed(Duration.zero); + pending.complete(); + expect(await oldSetup, isFalse); + expect(await newSetup, isTrue); + await teardown; + expect(writes.last, 'new-active'); + await current.dispose(() async { + writes.add('new-restore'); + }); + expect(writes.last, 'new-restore'); + }, + ); + } +} diff --git a/test/player_lifecycle_regression_test.dart b/test/player_lifecycle_regression_test.dart new file mode 100644 index 0000000..6ae075f --- /dev/null +++ b/test/player_lifecycle_regression_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; + +void main() { + for (final pendingStep in [0, 1, 2]) { + test( + 'dispose during setup step $pendingStep cancels later writes; restore is last', + () async { + final lifecycle = PlaybackLifecycle(); + final entered = Completer(); + final release = Completer(); + final events = []; + final setup = lifecycle.setup([ + for (var i = 0; i < 3; i++) + () async { + if (i == pendingStep) { + entered.complete(); + await release.future; + } + events.add('setup:$i'); + }, + ]); + await entered.future; + final restore = lifecycle.dispose(() async => events.add('restore')); + release.complete(); + expect(await setup, isFalse); + await restore; + expect(events, [ + for (var i = 0; i <= pendingStep; i++) 'setup:$i', + 'restore', + ]); + }, + ); + } + test('dispose during save prevents any player open dispatch', () async { + final lifecycle = PlaybackLifecycle(); + final save = Completer(); + var opens = 0; + final opening = lifecycle.open( + save: () => save.future, + dispatch: (_) async { + opens++; + }, + ); + await lifecycle.dispose(() async {}); + save.complete(); + expect(await opening, isFalse); + expect(opens, 0); + }); + test('newer open wins even when an older save completes last', () async { + final lifecycle = PlaybackLifecycle(); + final save = Completer(); + final opened = []; + final older = lifecycle.open( + save: () => save.future, + dispatch: (_) async => opened.add('old'), + ); + expect( + await lifecycle.open( + save: () async {}, + dispatch: (_) async => opened.add('new'), + ), + isTrue, + ); + save.complete(); + expect(await older, isFalse); + expect(opened, ['new']); + }); + test('disposed lifecycle rejects new setup and open work', () async { + final lifecycle = PlaybackLifecycle(); + await lifecycle.dispose(() async {}); + var calls = 0; + expect( + await lifecycle.setup([ + () async { + calls++; + }, + ]), + isFalse, + ); + expect( + await lifecycle.open( + save: () async { + calls++; + }, + dispatch: (_) async { + calls++; + }, + ), + isFalse, + ); + expect(calls, 0); + }); +} diff --git a/test/player_native_session_test.dart b/test/player_native_session_test.dart new file mode 100644 index 0000000..38ab813 --- /dev/null +++ b/test/player_native_session_test.dart @@ -0,0 +1,406 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late List actions; + late List native; + late PlayerNativeSession session; + setUp(() { + actions = []; + native = []; + session = PlayerNativeSession( + invoke: (method, args) async { + native.add(MethodCall(method, args)); + return true; + }, + local: (action, position) async { + actions.add('$action:${position?.inMilliseconds}'); + }, + media: () => { + 'url': 'https://media.test/v.mp4', + 'title': 'Episode 2', + 'positionMs': 12000, + 'subtitleUrl': 'https://media.test/fa.srt', + }, + castRequested: true, // These protocol tests start with a user's request. + isPlaying: () => true, + isCurrent: () => true, + ); + session.claim(); + }); + tearDown(() => session.release()); + test( + 'media actions dispatch actual local controls and reject unknown actions', + () async { + await session.handle( + const MethodCall('mediaAction', {'action': 'seek', 'positionMs': 4500}), + ); + await session.handle(const MethodCall('mediaAction', {'action': 'next'})); + await session.handle( + const MethodCall('mediaAction', {'action': 'invented'}), + ); + expect(actions, ['seek:4500', 'next:null']); + }, + ); + test('Cast transfer pauses decoder and routes controls remotely until disconnect', () async { + await session.handle( + const MethodCall('castChanged', {'connected': true, 'deviceName': 'TV'}), + ); + expect(session.casting, true); + expect(actions, ['pause:null']); + expect(native.single.method, 'castLoad'); + expect(native.single.arguments['positionMs'], 12000); + expect(native.single.arguments['subtitleUrl'], 'https://media.test/fa.srt'); + await session.command('seek', position: const Duration(seconds: 30)); + expect(native.last.method, 'castCommand'); + expect(native.last.arguments, {'action': 'seek', 'positionMs': 30000}); + await session.handle( + const MethodCall('castChanged', { + 'connected': true, + 'playing': true, + 'positionMs': 33000, + 'durationMs': 60000, + }), + ); + await session.handle(const MethodCall('castChanged', {'connected': false})); + expect(actions, ['pause:null', 'seek:33000', 'play:null']); + expect(session.casting, false); + }); + test( + 'failed Cast load restores local playback and leaves no remote mode', + () async { + session.invoke = (method, args) async => false; + await session.handle( + const MethodCall('castChanged', {'connected': true}), + ); + expect(session.casting, false); + expect(actions, ['pause:null', 'play:null']); + }, + ); + test('failed Cast load is not retried by repeated connected polls', () async { + session.invoke = (method, args) async { + native.add(MethodCall(method, args)); + return false; + }; + await session.handle(const MethodCall('castChanged', {'connected': true})); + await session.handle(const MethodCall('castChanged', {'connected': true})); + await session.handle(const MethodCall('castChanged', {'connected': true})); + expect(native.where((call) => call.method == 'castLoad'), hasLength(1)); + expect(actions, ['pause:null', 'play:null']); + // A real reconnect may retry once; ordinary state polling may not. + await session.handle(const MethodCall('castChanged', {'connected': false})); + await session.handle(const MethodCall('castChanged', {'connected': true})); + expect(native.where((call) => call.method == 'castLoad'), hasLength(2)); + }); + test( + 'old owner cannot clear new handler or dispatch pending action', + () async { + final old = session; + final writes = []; + final newer = PlayerNativeSession( + invoke: old.invoke, + local: (a, p) async => writes.add(a), + media: () => {}, + isPlaying: () => false, + isCurrent: () => true, + )..claim(); + old.release(); + await old.handle(const MethodCall('mediaAction', {'action': 'play'})); + await newer.handle(const MethodCall('pipChanged', true)); + await newer.handle(const MethodCall('mediaAction', {'action': 'pause'})); + expect(actions, isEmpty); + expect(writes, ['pause']); + expect(newer.inPip, true); + newer.release(); + }, + ); + test( + 'disconnect during pending load cannot revive stale Cast mode', + () async { + final load = Completer(); + session.invoke = (method, args) => load.future; + final pending = session.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + await session.handle( + const MethodCall('castChanged', {'connected': false}), + ); + load.complete(true); + await pending; + expect(session.casting, false); + expect(actions, ['pause:null', 'play:null']); + }, + ); + for (final intent in [true, false]) { + test('disconnect during awaited local pause restores latest intent $intent', () async { + final pause = Completer(); + var firstPause = true; + final writes = []; + final pendingSession = PlayerNativeSession( + castRequested: true, + invoke: (method, _) async { + writes.add(method); + return true; + }, + local: (action, _) async { + writes.add(action); + if (action == 'pause' && firstPause) { + firstPause = false; + await pause.future; + } + }, + media: () => {'url': 'https://media.test/current.mp4'}, + isPlaying: () => true, + isCurrent: () => true, + )..claim(); + addTearDown(pendingSession.release); + final load = pendingSession.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + expect(writes, ['pause']); + await pendingSession.command(intent ? 'play' : 'pause'); + await pendingSession.handle( + const MethodCall('castChanged', {'connected': false}), + ); + // Repeated disconnected polling must not discard the restoration reason. + await pendingSession.handle( + const MethodCall('castChanged', {'connected': false}), + ); + pause.complete(); + await load; + expect(writes, [ + 'pause', + intent ? 'play' : 'pause', + intent ? 'play' : 'pause', + ]); + expect(pendingSession.casting, false); + }); + } + for (final cancellation in ['stop', 'release', 'new owner', 'new load']) { + test( + 'awaited pause cancellation cannot resume after $cancellation', + () async { + final pause = Completer(); + var firstPause = true; + final writes = []; + final pendingSession = PlayerNativeSession( + castRequested: true, + invoke: (method, _) async { + writes.add(method); + return true; + }, + local: (action, _) async { + writes.add(action); + if (action == 'pause' && firstPause) { + firstPause = false; + await pause.future; + } + }, + media: () => {'url': 'https://media.test/current.mp4'}, + isPlaying: () => true, + isCurrent: () => true, + )..claim(); + addTearDown(pendingSession.release); + final load = pendingSession.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + await pendingSession.handle( + const MethodCall('castChanged', {'connected': false}), + ); + Future? newerLoad; + if (cancellation == 'stop') { + await pendingSession.command('stop'); + } else if (cancellation == 'release') { + pendingSession.release(); + } else if (cancellation == 'new load') { + newerLoad = pendingSession.loadCast(); + } else { + final newer = PlayerNativeSession( + invoke: (_, _) async => true, + local: (action, _) async => writes.add('new:$action'), + media: () => {}, + isPlaying: () => false, + isCurrent: () => true, + )..claim(); + addTearDown(newer.release); + } + pause.complete(); + await load; + await newerLoad; + expect(writes, cancellation == 'stop' ? ['pause', 'stop'] : ['pause']); + expect(pendingSession.casting, false); + }, + ); + } + for (final initiallyPlaying in [true, false]) { + for (final throws in [false, true]) { + test( + 'Cast ${throws ? 'throw' : 'rejection'} preserves newer ${initiallyPlaying ? 'pause' : 'play'} intent', + () async { + final response = Completer(); + final writes = []; + final failures = []; + final pendingSession = PlayerNativeSession( + castRequested: true, + invoke: (_, _) => response.future, + local: (action, _) async => writes.add(action), + media: () => {'url': 'https://media.test/current.mp4'}, + isPlaying: () => initiallyPlaying, + isCurrent: () => true, + failure: failures.add, + )..claim(); + addTearDown(pendingSession.release); + final load = pendingSession.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + final intent = initiallyPlaying ? 'pause' : 'play'; + await pendingSession.command(intent); + if (throws) { + response.completeError(StateError('native load failed')); + } else { + response.complete(false); + } + await load; + expect(writes, ['pause', intent, intent]); + expect(pendingSession.casting, false); + expect(failures, hasLength(1)); + }, + ); + } + } + for (final cancellation in ['stop', 'release', 'new owner', 'new load']) { + test( + 'stale throwing Cast load cannot overwrite state after $cancellation', + () async { + final response = Completer(); + final writes = []; + final failures = []; + var loads = 0; + final pendingSession = PlayerNativeSession( + castRequested: true, + invoke: (method, _) async { + if (method == 'castLoad' && ++loads == 1) return response.future; + return true; + }, + local: (action, _) async => writes.add(action), + media: () => {'url': 'https://media.test/current.mp4'}, + isPlaying: () => true, + isCurrent: () => true, + failure: failures.add, + )..claim(); + addTearDown(pendingSession.release); + final load = pendingSession.handle( + const MethodCall('castChanged', {'connected': true}), + ); + await Future.delayed(Duration.zero); + Future? newerLoad; + if (cancellation == 'stop') { + await pendingSession.command('stop'); + } else if (cancellation == 'release') { + pendingSession.release(); + } else if (cancellation == 'new load') { + newerLoad = pendingSession.loadCast(); + } else { + final newer = PlayerNativeSession( + invoke: (_, _) async => true, + local: (action, _) async => writes.add('new:$action'), + media: () => {}, + isPlaying: () => false, + isCurrent: () => true, + )..claim(); + addTearDown(newer.release); + } + response.completeError(StateError('malicious late native failure')); + await load; + await newerLoad; + expect(failures, isEmpty); + expect( + writes, + cancellation == 'stop' + ? ['pause', 'stop'] + : cancellation == 'new load' + ? ['pause', 'pause'] + : ['pause'], + ); + expect(pendingSession.casting, cancellation == 'new load'); + }, + ); + } + test( + 'notification stop ends background session without automatic restart', + () async { + await session.background( + enabled: true, + playing: true, + position: Duration.zero, + duration: Duration.zero, + ); + await session.command('stop'); + expect(native.last.method, 'backgroundPlaybackStop'); + final count = native.length; + await session.background( + enabled: true, + playing: false, + position: Duration.zero, + duration: Duration.zero, + ); + expect(native.length, count); + await session.command('play'); + await session.background( + enabled: true, + playing: true, + position: Duration.zero, + duration: Duration.zero, + ); + expect(native.last.method, 'backgroundPlayback'); + }, + ); + test( + 'background bridge starts only by opt-in and updates exact metadata', + () async { + await session.background( + enabled: false, + playing: true, + position: Duration.zero, + duration: Duration.zero, + ); + expect(native, isEmpty); + await session.background( + enabled: true, + playing: true, + position: const Duration(seconds: 12), + duration: const Duration(seconds: 60), + ); + expect(native.last.method, 'backgroundPlayback'); + expect(native.last.arguments, { + 'enabled': true, + 'title': 'Episode 2', + 'playing': true, + 'positionMs': 12000, + 'durationMs': 60000, + }); + await session.background( + enabled: true, + playing: false, + position: const Duration(seconds: 15), + duration: const Duration(seconds: 60), + ); + expect(native.last.method, 'backgroundPlaybackUpdate'); + await session.background( + enabled: false, + playing: false, + position: Duration.zero, + duration: Duration.zero, + ); + expect(native.last.method, 'backgroundPlaybackStop'); + }, + ); +} diff --git a/test/player_preferences_next_test.dart b/test/player_preferences_next_test.dart new file mode 100644 index 0000000..ebd5701 --- /dev/null +++ b/test/player_preferences_next_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; +import 'package:reelbox/state/app_state.dart'; + +void main() { + test('startup dispatch applies rate/audio but does not overwrite system volume by default', () async { + final calls = []; + await applyPlayerOptions(AppSettings(playbackSpeed:1.75,audioOnly:true,playerVolume:.9), + rate:(v) async => calls.add('rate:$v'), audioOnly:(v) async => calls.add('audio:$v'), volume:(v) async => calls.add('volume:$v')); + expect(calls,['rate:1.75','audio:true']); + await applyPlayerOptions(AppSettings(rememberVolume:true,playerVolume:.2), + rate:(v) async {}, audioOnly:(v) async {}, volume:(v) async => calls.add('volume:$v')); + expect(calls.last,'volume:0.2'); + }); + test('subtitle reset only resets subtitle fields; complete player reset preserves library preferences', () { + final settings = AppSettings(locale:'fa',theme:'light',skipSeconds:30,subtitleEnabled:false,subtitleFontFamily:'serif',subtitleFontSize:35,subtitleDelay:2,audioDelay:3,videoFit:'cover',audioOnly:true,playbackSpeed:1.75,backgroundAudio:true,externalPlayerPackage:'org.videolan.vlc'); + final sub = resetSubtitleSettings(settings); + expect(sub.subtitleEnabled,true); expect(sub.subtitleFontFamily,'sans-serif'); + expect(sub.subtitleFontSize,24); expect(sub.subtitleDelay,0); + expect(sub.audioDelay,3); expect(sub.playbackSpeed,1.75); expect(sub.locale,'fa'); + final all = resetPlayerSettings(settings); + expect(all.videoFit,'contain'); expect(all.audioOnly,false); expect(all.playbackSpeed,1); + expect(all.audioDelay,0); expect(all.backgroundAudio,false); expect(all.skipSeconds,10); + expect(all.locale,'fa'); expect(all.theme,'light'); + }); +} diff --git a/test/player_subtitle_encoding_test.dart b/test/player_subtitle_encoding_test.dart new file mode 100644 index 0000000..22de63d --- /dev/null +++ b/test/player_subtitle_encoding_test.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_helpers.dart'; + +void main() { + test('decodes real Windows-1256 Persian SRT fixture without replacement', () { + final bytes = File('test/fixtures/player_persian_cp1256.srt').readAsBytesSync(); + final text = decodeSubtitle(bytes); + expect(text, contains('سلام دنيا؛ پخش گچ پژ')); + expect(text, contains('00:00:01,000 --> 00:00:03,000')); + expect(text, isNot(contains('\uFFFD'))); + }); + test('UTF8 BOM and UTF16LE preserve subtitle text', () { + expect(decodeSubtitle([239,187,191,...utf8.encode('سلام')]), 'سلام'); + expect(decodeSubtitle([255,254,0x33,0x06,0x44,0x06,0x27,0x06,0x45,0x06]), 'سلام'); + }); +} diff --git a/test/player_system_controls_test.dart b/test/player_system_controls_test.dart new file mode 100644 index 0000000..23653e7 --- /dev/null +++ b/test/player_system_controls_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/foundation.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, null); + }); + test('player reads native control values and restores original window brightness', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final writes = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, (call) async { + switch (call.method) { + case 'getBrightness': + return -1.0; + case 'getEffectiveBrightness': + return .65; + case 'getVolume': + return .22; + case 'setBrightness': + writes.add((call.arguments as Map)['value'] as double); + return null; + } + return null; + }); + final controls = PlayerSystemControls(); + await controls.capture(); + expect(controls.brightness, .65); + expect(controls.volume, .22); + await controls.restore(); + expect(writes, [-1.0]); + }); + test( + 'leaving a player that never initialized does not change brightness', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, (call) async { + called = true; + return null; + }); + await PlayerSystemControls().restore(); + expect(called, isFalse); + }, + ); +} diff --git a/test/player_widget_test.dart b/test/player_widget_test.dart new file mode 100644 index 0000000..16351f5 --- /dev/null +++ b/test/player_widget_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/player_page.dart'; +import 'package:reelbox/state/app_state.dart'; + +void main() { + testWidgets( + 'Untrusted source is rejected before initializing a native player', + (tester) async { + final state = AppState(); + await tester.pumpWidget( + AppScope( + state: state, + child: const MaterialApp( + home: PlayerPage( + title: TitleRecord(id: 1, title: 'Invalid source'), + source: MediaSource(url: 'javascript:alert(1)'), + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Unable to play this source'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }, + ); +} diff --git a/test/segmented_classification_test.dart b/test/segmented_classification_test.dart new file mode 100644 index 0000000..2bc2808 --- /dev/null +++ b/test/segmented_classification_test.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/media/media_helpers.dart'; +import 'package:reelbox/media/media_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final calls = []; + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, (call) async { + calls.add(call); + return true; + }); + }); + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MediaPlatform.channel, null); + }); + + for (final type in [ + 'mpd', ' MPD ', 'hls', ' HLS ', 'm3u8', 'M3U8', 'dash', ' DASH ', + 'application/dash+xml', 'application/vnd.apple.mpegurl', + 'application/x-mpegURL; charset=UTF-8', + ]) { + test('extensionless $type is not a direct-file or external-downloader source', () async { + final source = MediaSource(url: 'https://example.test/manifest?id=7', type: type); + expect(isSegmentedMediaSource(source), isTrue); + final entry = DownloadEntry.fromTask(DownloadTask( + taskId: 'manifest', url: source.url, filename: 'legacy.mp4', + metaData: jsonEncode({'titleId': 7, 'title': 'Fixture', 'sourceType': type}), + ), taskStatus: TaskStatus.failed); + await expectLater(DownloadService.instance.externalDownloader(entry), + throwsA(isA().having((e) => e.message, 'message', contains('package')))); + expect(calls, isEmpty); + expect(downloadUnsupportedReason(source), isNotNull); + }); + } + + test('manifest URL extensions override direct type hints', () { + for (final extension in ['M3U8', 'MPD']) { + expect(downloadUnsupportedReason(MediaSource( + url: 'https://example.test/video.$extension?name=movie.mp4', type: 'mp4', + )), isNotNull); + } + }); + + test('direct MP4 remains eligible for direct and external download', () async { + const source = MediaSource(url: 'https://example.test/video.mp4', type: 'mp4'); + expect(downloadUnsupportedReason(source), isNull); + final entry = DownloadEntry.fromTask(DownloadTask( + taskId: 'direct', url: source.url, filename: 'video.mp4', + metaData: jsonEncode({'titleId': 7, 'title': 'Fixture', 'sourceType': 'mp4'}), + )); + await DownloadService.instance.externalDownloader(entry); + expect(calls.map((call) => call.method), ['openDownloader']); + }); +} diff --git a/test/segmented_download_test.dart b/test/segmented_download_test.dart new file mode 100644 index 0000000..a828c6f --- /dev/null +++ b/test/segmented_download_test.dart @@ -0,0 +1,371 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/segmented_download.dart'; + +void main() { + late Directory root; + late HttpServer server; + late String origin; + final requests = []; + final fixtures = {}; + final failures = {}; + final redirects = {}; + final responseCodes = {}; + final encodings = []; + var incorrectRange = false; + var active = 0, peak = 0; + setUp(() async { + root = await Directory.systemTemp.createTemp('reelbox-offline-test-'); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + origin = 'http://${server.address.address}:${server.port}'; + requests.clear(); fixtures.clear(); failures.clear(); redirects.clear(); active = 0; peak = 0; + responseCodes.clear(); encodings.clear(); incorrectRange = false; + server.listen((request) async { + requests.add('${request.uri.path}:${request.headers.value('range') ?? ''}'); + encodings.add('${request.uri.path}:${request.headers.value('accept-encoding') ?? ''}'); + active++; if (active > peak) peak = active; + try { + final path = request.uri.path; + request.response.statusCode = responseCodes[path] ?? 200; + if (failures.contains(path)) { + request.response.statusCode = 403; + } else if (redirects.containsKey(path)) { + request.response.statusCode = responseCodes[path] ?? 302; + request.response.headers.set(HttpHeaders.locationHeader, redirects[path]!); + } else if (fixtures.containsKey(path)) { + await Future.delayed(const Duration(milliseconds: 15)); + final bytes = utf8.encode(fixtures[path]!); + request.response.contentLength = bytes.length; + request.response.add(bytes); + } else if (path == '/master.m3u8') { + request.response.write('#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nvideo/list.m3u8\n'); + } else if (path == '/video/list.m3u8') { + request.response.write('#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXT-X-MAP:URI="../blob",BYTERANGE="4@0"\n#EXTINF:2,\n#EXT-X-BYTERANGE:4@4\n../blob\n#EXTINF:2,\n#EXT-X-BYTERANGE:4\n../blob\n#EXT-X-ENDLIST\n'); + } else if (path == '/blob') { + await Future.delayed(const Duration(milliseconds: 20)); + final range = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(request.headers.value('range') ?? '')!; + final start = int.parse(range[1]!), end = int.parse(range[2]!); + request.response.statusCode = 206; + request.response.headers.set('content-range', incorrectRange ? 'bytes 0-0/12' : 'bytes $start-$end/12'); + request.response.contentLength = end - start + 1; + request.response.add(utf8.encode('INITAAAABBBB').sublist(start, end + 1)); + } else { + request.response.statusCode = 404; + } + } finally { + await request.response.close(); active--; + } + }); + }); + tearDown(() async { await server.close(force: true); await root.delete(recursive: true); }); + + test('HLS master downloads bounded parallel ranges and publishes fully local validated package', () async { + final engine = SegmentedDownload(root: root, parallel: 2); + await engine.prepare(id: 'hls', url: '$origin/master.m3u8'); + final result = await engine.run('hls'); + expect(result.status, 'complete'); + expect(await engine.validate('hls'), isTrue); + expect(peak, 2); + expect(requests, containsAll(['/blob:bytes=0-3', '/blob:bytes=4-7', '/blob:bytes=8-11'])); + final resources = result.resources; + expect(resources.length, 3); + for (final resource in resources) { + final bytes = await File('${result.directory}/${resource.filename}').readAsBytes(); + expect(bytes.length, 4); + expect(sha256.convert(bytes).toString(), resource.digest); + } + final playlists = await Directory(result.directory).list().where((f) => f.path.endsWith('.m3u8')).toList(); + expect(playlists.length, 2); + for (final playlist in playlists) { + final text = await File(playlist.path).readAsString(); + expect(text, isNot(contains(origin))); + expect(text, isNot(contains('BYTERANGE'))); + } + final zip = ZipDecoder().decodeBytes(await File(await engine.export('hls')).readAsBytes()); + expect(zip.files.map((f)=>f.name).toSet(), {...result.manifests.keys,...resources.map((r)=>r.filename)}); + expect(zip.files.any((f)=>f.name=='package.json'),false); + for(final r in resources) { + expect(sha256.convert(zip.findFile(r.filename)!.content).toString(),r.digest); + } + await File('${result.directory}/${resources.last.filename}').writeAsString('BAD!'); + expect(await engine.validate('hls'), isFalse); + }); + test('redirected HLS master and children use effective URIs for all resources', () async { + redirects.addAll({ + '/entry/master.m3u8': '/media/master.m3u8', + '/media/video.m3u8': 'video/list.m3u8', + '/media/audio.m3u8': 'audio/list.m3u8', + '/media/subs.m3u8': 'subs/list.m3u8', + }); + fixtures['/media/master.m3u8'] = '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Audio",DEFAULT=YES,URI="audio.m3u8"\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub",NAME="Subs",DEFAULT=YES,URI="subs.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=100,AUDIO="aud",SUBTITLES="sub"\nvideo.m3u8\n'; + fixtures['/media/video/list.m3u8'] = '#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key",IV=0x00000000000000000000000000000001\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n'; + fixtures['/media/audio/list.m3u8'] = '#EXTM3U\n#EXTINF:1,\na.aac\n#EXT-X-ENDLIST\n'; + fixtures['/media/subs/list.m3u8'] = '#EXTM3U\n#EXTINF:1,\na.vtt\n#EXT-X-ENDLIST\n'; + fixtures.addAll({ + '/media/video/key': '0123456789abcdef', + '/media/video/init.mp4': 'INIT', '/media/video/a.ts': 'VIDEO', + '/media/audio/a.aac': 'AUDIO', '/media/subs/a.vtt': 'WEBVTT\n', + }); + final engine = SegmentedDownload(root: root); + final planned = await engine.prepare(id: 'redirect_hls', url: '$origin/entry/master.m3u8'); + expect(planned.resources.map((r) => Uri.parse(r.url).path).toSet(), { + '/media/video/key', '/media/video/init.mp4', '/media/video/a.ts', + '/media/audio/a.aac', '/media/subs/a.vtt', + }); + final result = await engine.run(planned.id); + expect(result.status, 'complete', reason: result.error); + expect(await engine.validate(planned.id), isTrue); + expect(requests.any((r) => r.startsWith('/entry/') && r != '/entry/master.m3u8:'), isFalse); + }); + + test('resource redirects validate each hop before fetching excluded targets', () async { + fixtures['/vod.m3u8'] = '#EXTM3U\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n'; + redirects.addAll({'/a.ts': '/allowed-hop', '/allowed-hop': '/moviebox/support/blocked.ts'}); + fixtures['/moviebox/support/blocked.ts'] = 'MUST NOT FETCH'; + final engine = SegmentedDownload(root: root); + await engine.prepare(id: 'blocked_resource', url: '$origin/vod.m3u8'); + final result = await engine.run('blocked_resource'); + expect(result.status, 'failed'); + expect(result.error, contains('authorized')); + expect(requests, ['/vod.m3u8:', '/a.ts:', '/allowed-hop:']); + expect(await File(result.path).exists(), isFalse); + expect(await engine.validate(result.id), isFalse); + }); + + test('redirected extensionless DASH resolves nested BaseURLs at the effective URI', () async { + redirects['/entry/manifest'] = '/media/movie/manifest'; + fixtures['/media/movie/manifest'] = 'parts/period/video/main/'; + fixtures.addAll({ + '/media/movie/parts/period/video/main/init.mp4': 'INIT', + '/media/movie/parts/period/video/main/a.m4s': 'VIDEO', + }); + final engine = SegmentedDownload(root: root); + final planned = await engine.prepare(id: 'redirect_dash', url: '$origin/entry/manifest', type: 'mpd'); + expect(planned.manifest, 'offline.mpd'); + expect(planned.resources.map((r) => Uri.parse(r.url).path).toSet(), { + '/media/movie/parts/period/video/main/init.mp4', + '/media/movie/parts/period/video/main/a.m4s', + }); + final result = await engine.run(planned.id); + expect(result.status, 'complete', reason: result.error); + expect(await engine.validate(result.id), isTrue); + expect(await File(result.path).readAsString(), isNot(contains('BaseURL'))); + }); + + for (final code in [301, 302, 303, 307, 308]) { + test('manifest HTTP $code resolves relative Location against each hop', () async { + redirects.addAll({'/entry/start': 'next', '/entry/next': '../media/vod'}); + responseCodes.addAll({'/entry/start': code, '/entry/next': code}); + fixtures['/media/vod'] = '#EXTM3U\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n'; + fixtures['/media/a.ts'] = 'MEDIA'; + final engine = SegmentedDownload(root: root); + await engine.prepare(id: 'status_$code', url: '$origin/entry/start'); + final result = await engine.run('status_$code'); + expect(result.status, 'complete', reason: result.error); + expect(requests, ['/entry/start:', '/entry/next:', '/media/vod:', '/media/a.ts:']); + }); + } + + test('manifest redirects validate intermediate targets before requesting them', () async { + redirects.addAll({'/entry': '/allowed', '/allowed': '/moviebox/news/manifest'}); + fixtures['/moviebox/news/manifest'] = '#EXTM3U\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n'; + final engine = SegmentedDownload(root: root); + await expectLater(engine.prepare(id: 'denied_manifest', url: '$origin/entry'), throwsUnsupportedError); + expect(requests, ['/entry:', '/allowed:']); + expect(await Directory('${root.path}/denied_manifest').exists(), isFalse); + }); + + for (final target in ['file:///private/media', 'http://user:secret@127.0.0.1/media', '/moviebox/%73upport/media']) { + test('manifest redirects reject unauthorized target $target', () async { + redirects['/entry'] = target; + final engine = SegmentedDownload(root: root); + await expectLater(engine.prepare(id: 'unauthorized', url: '$origin/entry'), throwsUnsupportedError); + expect(requests, ['/entry:']); + }); + } + + test('manifest and resource redirect loops stop after five hops', () async { + redirects['/loop'] = '/loop'; + final engine = SegmentedDownload(root: root); + await expectLater(engine.prepare(id: 'loop_manifest', url: '$origin/loop'), + throwsA(isA().having((e) => e.message, 'message', contains('Too many')))); + expect(requests, List.filled(6, '/loop:')); + expect(await Directory('${root.path}/loop_manifest').exists(), isFalse); + requests.clear(); + fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\nloop\n#EXT-X-ENDLIST\n'; + await engine.prepare(id: 'loop_resource', url: '$origin/vod'); + final result = await engine.run('loop_resource'); + expect(result.status, 'failed'); + expect(result.error, contains('Too many')); + expect(requests, ['/vod:', ...List.filled(6, '/loop:')]); + expect(await File(result.path).exists(), isFalse); + }); + + test('manifest and resource redirects without Location fail closed', () async { + fixtures['/missing'] = ''; + responseCodes['/missing'] = 302; + final engine = SegmentedDownload(root: root); + await expectLater(engine.prepare(id: 'missing_manifest', url: '$origin/missing'), + throwsA(isA().having((e) => e.message, 'message', contains('Location')))); + fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\nmissing\n#EXT-X-ENDLIST\n'; + await engine.prepare(id: 'missing_resource', url: '$origin/vod'); + final result = await engine.run('missing_resource'); + expect(result.status, 'failed'); + expect(result.error, contains('Location')); + expect(requests, ['/missing:', '/vod:', '/missing:']); + }); + + test('resource redirects preserve Range and identity headers on every hop', () async { + fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\n#EXT-X-BYTERANGE:4@4\nrange\n#EXT-X-ENDLIST\n'; + redirects.addAll({'/range': '/range-hop', '/range-hop': '/blob'}); + final engine = SegmentedDownload(root: root); + await engine.prepare(id: 'redirect_range', url: '$origin/vod'); + final result = await engine.run('redirect_range'); + expect(result.status, 'complete', reason: result.error); + expect(requests, ['/vod:', '/range:bytes=4-7', '/range-hop:bytes=4-7', '/blob:bytes=4-7']); + expect(encodings.skip(1), ['/range:identity', '/range-hop:identity', '/blob:identity']); + expect(await File('${result.directory}/${result.resources.single.filename}').readAsString(), 'AAAA'); + expect(await engine.validate(result.id), isTrue); + }); + + for (final mode in ['ignored', 'incorrect']) { + test('redirected resource still rejects $mode byte range responses', () async { + fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\n#EXT-X-BYTERANGE:4@4\nrange\n#EXT-X-ENDLIST\n'; + redirects['/range'] = mode == 'ignored' ? '/whole' : '/blob'; + fixtures['/whole'] = 'INITAAAABBBB'; + incorrectRange = mode == 'incorrect'; + final engine = SegmentedDownload(root: root); + await engine.prepare(id: 'bad_range', url: '$origin/vod'); + final result = await engine.run('bad_range'); + expect(result.status, 'failed'); + expect(result.error, contains(mode == 'ignored' ? 'Resource HTTP 200' : 'Incorrect Content-Range')); + expect(await File(result.path).exists(), isFalse); + }); + } + + for (final code in [401, 403]) { + test('redirected manifests and resources retain HTTP $code denials', () async { + redirects['/entry'] = '/denied'; + fixtures['/denied'] = 'Access denied'; + responseCodes['/denied'] = code; + final engine = SegmentedDownload(root: root); + await expectLater(engine.prepare(id: 'denied_manifest', url: '$origin/entry'), + throwsA(isA().having((e) => e.message, 'message', 'Manifest HTTP $code'))); + fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\nentry\n#EXT-X-ENDLIST\n'; + await engine.prepare(id: 'denied_resource', url: '$origin/vod'); + final result = await engine.run('denied_resource'); + expect(result.status, 'failed'); + expect(result.error, contains('Resource HTTP $code')); + expect(await File(result.path).exists(), isFalse); + expect(requests, ['/entry:', '/denied:', '/vod:', '/entry:', '/denied:']); + }); + } + + test('HLS refuses encrypted init maps without explicit IV before creating a package', () async { + fixtures['/encrypted.m3u8'] = '#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXT-X-KEY:METHOD=AES-128,URI="key"\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:2,\na.ts\n#EXT-X-ENDLIST\n'; + final engine = SegmentedDownload(root: root); + await expectLater(engine.prepare(id:'unsafe',url:'$origin/encrypted.m3u8'),throwsUnsupportedError); + expect(await Directory('${root.path}/unsafe').exists(),isFalse); + }); + test('cancel/restart reuses only hash-validated complete segments and never publishes incomplete manifests', () async { + fixtures['/vod.m3u8']='#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n#EXT-X-ENDLIST\n'; + fixtures.addAll({'/a.ts':'AAAA','/b.ts':'BBBB','/c.ts':'CCCC'}); + final engine=SegmentedDownload(root:root,parallel:1); + await engine.prepare(id:'resume',url:'$origin/vod.m3u8',metadata:{'notifications':false}); + var stopping=false; + final run=engine.run('resume',onProgress:(p) { + if(p.receivedBytes>=4 && !stopping) { stopping=true; engine.stop('resume'); } + }); + final paused=await run; + expect(paused.status,'paused'); + expect(await File(paused.path).exists(),isFalse); + expect(await engine.validate('resume'),isFalse); + final stateFile=File('${paused.directory}/package.json'); + final saved=jsonDecode(await stateFile.readAsString()) as Map; + saved['status']='running'; // OS termination before a status checkpoint. + await stateFile.writeAsString(jsonEncode(saved)); + final restarted=SegmentedDownload(root:root,parallel:2); + final recovered=(await restarted.recover()).single; + expect(recovered.status,'paused'); + expect(recovered.metadata['notifications'],false); + requests.clear(); + final completed=await restarted.run('resume'); + expect(completed.status,'complete'); + expect(requests, isNot(contains('/a.ts:'))); + expect(await restarted.validate('resume'),isTrue); + await File('${completed.directory}/${completed.resources.first.filename}').writeAsString('EVIL'); + final damaged=(await SegmentedDownload(root:root).recover()).single; + expect(damaged.status,'failed'); + requests.clear(); + final repaired=await restarted.run('resume'); + expect(repaired.status,'complete'); + expect(requests,['/a.ts:']); + expect(await restarted.validate('resume'),isTrue); + }); + test('denied resource is failed, retains validated progress, and retry repairs it', () async { + fixtures['/vod.m3u8']='#EXTM3U\n#EXTINF:1,\na.ts\n#EXTINF:1,\nb.ts\n#EXT-X-ENDLIST\n'; + fixtures.addAll({'/a.ts':'AAAA','/b.ts':'BBBB'}); + failures.add('/b.ts'); + final engine=SegmentedDownload(root:root,parallel:1); + await engine.prepare(id:'denied',url:'$origin/vod.m3u8'); + final failed=await engine.run('denied'); + expect(failed.status,'failed'); + expect(failed.receivedBytes,4); + expect(await File(failed.path).exists(),isFalse); + failures.clear(); requests.clear(); + expect((await SegmentedDownload(root:root).run('denied')).status,'complete'); + expect(requests,['/b.ts:']); + }); + test('AES-128 identity keys and separate default audio are packaged locally', () async { + fixtures['/audio-master.m3u8']='#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",DEFAULT=YES,URI="audio.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=500,AUDIO="aud"\nvideo.m3u8\n'; + fixtures['/audio.m3u8']='#EXTM3U\n#EXTINF:1,\na.aac\n#EXT-X-ENDLIST\n'; + fixtures['/video.m3u8']='#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key",IV=0x00000000000000000000000000000001\n#EXTINF:1,\nv.ts\n#EXT-X-ENDLIST\n'; + fixtures.addAll({'/a.aac':'AUDIO','/v.ts':'ENCRYPTED-MEDIA','/key':'0123456789abcdef'}); + final engine=SegmentedDownload(root:root); + await engine.prepare(id:'audio',url:'$origin/audio-master.m3u8'); + final p=await engine.run('audio'); + expect(p.status,'complete'); expect(p.resources.length,3); + expect(p.resources.singleWhere((r)=>r.key).bytes,16); + expect(await engine.validate('audio'),isTrue); + }); + test('reject DRM, live manifests, dynamic DASH and unbounded timelines', () async { + final engine=SegmentedDownload(root:root); + final cases={ + 'live':'#EXTM3U\n#EXTINF:1,\na.ts\n', + 'drm':'#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.apple.streamingkeydelivery",URI="skd://protected"\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n', + 'dashdrm':'', + 'dynamic':'', + 'unbounded':'', + }; + for(final entry in cases.entries) { + fixtures['/${entry.key}']=entry.value; + await expectLater(engine.prepare(id:entry.key,url:'$origin/${entry.key}'),throwsUnsupportedError,reason:entry.key); + expect(await Directory('${root.path}/${entry.key}').exists(),false); + } + }); + test('clear static DASH template packages audio and video with local SegmentLists', () async { + fixtures['/movie.mpd'] = '''media/'''; + for (final path in ['video/init.mp4','video/005.m4s','video/006.m4s','audio/init.mp4','audio/1.m4s','audio/2.m4s']) { + fixtures['/media/$path'] = 'BYTES:$path'; + } + final engine = SegmentedDownload(root: root, parallel: 3); + await engine.prepare(id: 'dash', url: '$origin/movie.mpd'); + final result = await engine.run('dash'); + expect(result.status, 'complete'); + expect(result.resources.length, 6); + expect(await engine.validate('dash'), isTrue); + final manifest = await File(result.path).readAsString(); + expect(manifest, contains('SegmentList')); + expect(manifest, isNot(contains('SegmentTemplate'))); + expect(manifest, isNot(contains('BaseURL'))); + expect(manifest, isNot(contains(origin))); + for (final r in result.resources) { + expect(await File('${result.directory}/${r.filename}').readAsString(), fixtures[Uri.parse(r.url).path]); + expect(manifest, contains(r.filename)); + } + }); +} diff --git a/test/subtitle_webvtt_test.dart b/test/subtitle_webvtt_test.dart new file mode 100644 index 0000000..f57b9c0 --- /dev/null +++ b/test/subtitle_webvtt_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/media/subtitle_webvtt.dart'; + +void main() { + test('WebVTT drops metadata, STYLE, REGION, NOTE, cue settings and unsafe markup', () { + final output = SubtitleWebVtt.convert('''WEBVTT synthetic header +X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:0 + +STYLE +::cue { background-image: url(https://unsafe.invalid/pixel) } + +REGION +id:remote + +NOTE private note +https://unsafe.invalid/note + +cue-id +00:01.250 --> 00:03.000 region:remote align:start +سلام & safe +'''); + expect( + output, + 'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام & safe\n\n', + ); + expect(output, isNot(contains('unsafe.invalid'))); + }); + test('SRT cue payload is sanitized, not simply relabelled', () { + expect( + SubtitleWebVtt.convert( + '1\n00:00:00,000 --> 00:00:01,500\nHello {\\\\an8}friend', + ), + 'WEBVTT\n\n00:00:00.000 --> 00:00:01.500\nHello friend\n\n', + ); + }); + for (final timing in [ + '00:61:00,000 --> 01:02:00,000', + '00:00:61,000 --> 00:02:00,000', + '00:00:03,000 --> 00:00:01,000', + '00:00:01,000 --> 00:00:01,000', + '-00:00:01,000 --> 00:00:02,000', + ]) { + test('rejects invalid cue timing $timing', () { + expect( + () => SubtitleWebVtt.convert('1\n$timing\nSynthetic'), + throwsFormatException, + ); + }); + } + test('rejects decreasing cue starts but permits overlapping cues', () { + expect( + () => SubtitleWebVtt.convert( + '1\n00:00:02,000 --> 00:00:04,000\na\n\n2\n00:00:01,000 --> 00:00:03,000\nb', + ), + throwsFormatException, + ); + expect( + SubtitleWebVtt.convert( + '1\n00:00:01,000 --> 00:00:04,000\na\n\n2\n00:00:02,000 --> 00:00:03,000\nb', + ), + contains('00:00:02.000 --> 00:00:03.000'), + ); + }); + for (final unsupported in [ + '', + 'TTML is not supported', + 'plain arbitrary text', + 'WEBVTT\n\nSTYLE\n::cue {color:red}', + ]) { + test('rejects empty/unsupported input honestly: $unsupported', () { + expect(() => SubtitleWebVtt.convert(unsupported), throwsFormatException); + }); + } + test('rejects invalid ASS time and drawing-mode dialogue', () { + for (final body in [ + 'Dialogue: 0,0:99:01.00,0:99:02.00,text', + r'Dialogue: 0,0:00:01.00,0:00:02.00,{\p1}m 1 1 l 2 2', + ]) { + expect( + () => SubtitleWebVtt.convert( + '[Script Info]\n[Events]\nFormat: Layer, Start, End, Text\n$body', + ), + throwsA(anyOf(isA(), isA())), + ); + } + }); + for (final format in ['ASS', 'SSA']) { + test('synthetic $format uses Events format and dialogue, never styles/fonts', () { + final fields = format == 'ASS' + ? 'Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text' + : 'Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'; + final output = SubtitleWebVtt.convert('''[Script Info] +Title: Synthetic only +[V4+ Styles] +Format: Name, Fontname +Style: Default,https://unsafe.invalid/font +[Events] +Format: $fields +Dialogue: 0,0:00:01.25,0:00:03.00,Default,,0,0,0,,{\\b1}سلام\\Nworld, again{\\b0} +'''); + expect( + output, + 'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام\nworld, again\n\n', + ); + expect(output, isNot(contains('unsafe.invalid'))); + }); + } + test('synthetic SRT becomes actual WebVTT while preserving Persian text', () { + expect( + SubtitleWebVtt.convert( + '\ufeff1\r\n00:00:01,250 --> 00:00:03,000\r\nسلام دنیا\r\n', + ), + 'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام دنیا\n\n', + ); + }); +} diff --git a/test/ui_computation_regression_test.dart b/test/ui_computation_regression_test.dart new file mode 100644 index 0000000..689ee8e --- /dev/null +++ b/test/ui_computation_regression_test.dart @@ -0,0 +1,69 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:reelbox/ui/common.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_service_test.dart' show StubAdapter, jsonBody; + +void main() { + testWidgets('artwork decodes at constrained thumbnail width, not fallback width', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MediaQuery( + data: MediaQueryData(devicePixelRatio: 2), + child: Center(child: SizedBox(width: 62, height: 88, + child: MediaArtwork(url: 'https://example.test/poster.jpg'), + )), + ))); + final image = tester.widget(find.byType(Image)); + final provider = image.image as ResizeImage; + debugPrint('UI_WORK artwork decodeWidth=${provider.width} target=124'); + expect(provider.width, 124); + expect(tester.getSize(find.byType(MediaArtwork)), const Size(62, 88)); + await tester.pumpWidget(const SizedBox()); + }); + + for (final compact in [false, true]) { + testWidgets('paged catalogue builds only nearby ${compact ? 'rows' : 'posters'}', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final pages = []; + final adapter = StubAdapter((request) { + if (request.uri.path.contains('/all/')) return jsonBody([]); + final page = int.parse(request.uri.pathSegments[7]); + pages.add(page); + return jsonBody(page == 0 ? List.generate(160, (i) => { + 'id': i + 1, 'type': 'movie', 'title': 'Fixture ${i + 1}', + }) : []); + }); + final state = AppState(api: ApiService(dio: Dio()..httpClientAdapter = adapter)); + await state.updateSettings(state.prefs.copyWith(compactCatalog: compact)); + addTearDown(state.dispose); + await tester.pumpWidget(AppScope(state: state, child: const MaterialApp(home: BrowsePage(initialCountry: 9)))); + await tester.pumpAndSettle(); + final visibleRows = compact + ? find.byWidgetPredicate((w) => w.key is ValueKey && (w.key! as ValueKey).value.startsWith('compact-title-row:')) + : find.byType(PosterCard); + final initialCount = visibleRows.evaluate().length; + debugPrint('UI_WORK paged compact=$compact initial=$initialCount total=160'); + expect(initialCount, greaterThan(0)); + expect(initialCount, lessThan(24), reason: 'offscreen titles must not be laid out eagerly'); + expect(find.text('Fixture 160'), findsNothing); + expect(pages, [0]); + final scrollable = find.byType(Scrollable).last; + await tester.scrollUntilVisible(find.text('Fixture 160'), 650, scrollable: scrollable, maxScrolls: 100); + await tester.ensureVisible(find.text('Fixture 160')); + await tester.pumpAndSettle(); + expect(find.text('Fixture 160'), findsOneWidget); + expect(pages, [0, 1]); + debugPrint('UI_WORK paged compact=$compact end=${visibleRows.evaluate().length} pages=$pages'); + expect(visibleRows.evaluate().length, lessThan(24)); + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..888a796 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _NavigationFixtureApi extends ApiService { + @override + Future> home() async => {'genres': []}; + + @override + Future initialize({bool discover = true}) async {} + + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => []; + + @override + Future> countries() async => []; +} + +void main() { + testWidgets('Account-free app exposes its five core destinations', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: _NavigationFixtureApi(), + storage: await SharedPreferences.getInstance(), + ); + await state.initialize(discover: false); + // This fixture exercises the shell, not first-run onboarding. + await state.updateSettings(state.prefs.copyWith(introCompleted: true)); + await tester.pumpWidget(MyApp(state: state)); + await tester.pumpAndSettle(); + for (final label in ['Discover', 'Browse', 'Search', 'Library', 'More']) { + expect( + find.text(label), + findsWidgets, + reason: 'Missing destination: $label', + ); + } + for (final label in [ + 'Sign in', + 'Register', + 'Subscribe', + 'Buy subscription', + ]) { + expect(find.text(label), findsNothing); + } + await tester.tap(find.byIcon(Icons.video_library_outlined).last); + await tester.pumpAndSettle(); + expect(find.text('Watchlist (0)'), findsOneWidget); + expect(find.text('Continue (0)'), findsOneWidget); + expect(find.text('Collections (0)'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }); +} diff --git a/third_party/DEPENDENCIES.json b/third_party/DEPENDENCIES.json new file mode 100644 index 0000000..ed98338 --- /dev/null +++ b/third_party/DEPENDENCIES.json @@ -0,0 +1,671 @@ +{ + "scope": "Resolved Dart/Flutter packages from the verified lockfile; native Android components are separately described in THIRD_PARTY_NOTICES.txt", + "packages": [ + { + "name": "android_file_picker", + "version": "2.1.2", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_android", + "license_file": "third_party/licenses/dart/android_file_picker.txt" + }, + { + "name": "android_intent_plus", + "version": "6.1.0", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/android_intent_plus", + "license_file": "third_party/licenses/dart/android_intent_plus.txt" + }, + { + "name": "archive", + "version": "4.6.0", + "upstream": "https://github.com/brendan-duncan/archive", + "license_file": "third_party/licenses/dart/archive.txt" + }, + { + "name": "args", + "version": "2.7.0", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/args", + "license_file": "third_party/licenses/dart/args.txt" + }, + { + "name": "async", + "version": "2.13.1", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/async", + "license_file": "third_party/licenses/dart/async.txt" + }, + { + "name": "background_downloader", + "version": "9.6.5", + "upstream": "https://github.com/781flyingdutchman/background_downloader", + "license_file": "third_party/licenses/dart/background_downloader.txt" + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/boolean_selector", + "license_file": "third_party/licenses/dart/boolean_selector.txt" + }, + { + "name": "characters", + "version": "1.4.1", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/characters", + "license_file": "third_party/licenses/dart/characters.txt" + }, + { + "name": "clock", + "version": "1.1.3", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/clock", + "license_file": "third_party/licenses/dart/clock.txt" + }, + { + "name": "code_assets", + "version": "1.2.1", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/code_assets", + "license_file": "third_party/licenses/dart/code_assets.txt" + }, + { + "name": "collection", + "version": "1.19.1", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/collection", + "license_file": "third_party/licenses/dart/collection.txt" + }, + { + "name": "connectivity_plus", + "version": "7.3.2", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus/connectivity_plus", + "license_file": "third_party/licenses/dart/connectivity_plus.txt" + }, + { + "name": "connectivity_plus_platform_interface", + "version": "2.1.0", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/", + "license_file": "third_party/licenses/dart/connectivity_plus_platform_interface.txt" + }, + { + "name": "cross_file", + "version": "0.3.5+5", + "upstream": "https://github.com/flutter/packages/tree/main/packages/cross_file", + "license_file": "third_party/licenses/dart/cross_file.txt" + }, + { + "name": "crypto", + "version": "3.0.8", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/crypto", + "license_file": "third_party/licenses/dart/crypto.txt" + }, + { + "name": "dbus", + "version": "0.8.0", + "upstream": "https://github.com/canonical/dbus.dart", + "license_file": "third_party/licenses/dart/dbus.txt" + }, + { + "name": "dio", + "version": "5.11.1", + "upstream": "https://github.com/cfug/dio/blob/main/dio", + "license_file": "third_party/licenses/dart/dio.txt" + }, + { + "name": "dio_web_adapter", + "version": "2.2.2", + "upstream": "https://github.com/cfug/dio/blob/main/plugins/web_adapter", + "license_file": "third_party/licenses/dart/dio_web_adapter.txt" + }, + { + "name": "fake_async", + "version": "1.3.3", + "upstream": "https://github.com/dart-lang/test/tree/master/pkgs/fake_async", + "license_file": "third_party/licenses/dart/fake_async.txt" + }, + { + "name": "ffi", + "version": "2.2.0", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/ffi", + "license_file": "third_party/licenses/dart/ffi.txt" + }, + { + "name": "ffi_leak_tracker", + "version": "0.1.2", + "upstream": "https://github.com/halildurmus/win32/tree/main/packages/ffi_leak_tracker", + "license_file": "third_party/licenses/dart/ffi_leak_tracker.txt" + }, + { + "name": "file", + "version": "7.0.1", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/file", + "license_file": "third_party/licenses/dart/file.txt" + }, + { + "name": "file_picker", + "version": "13.3.1", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker", + "license_file": "third_party/licenses/dart/file_picker.txt" + }, + { + "name": "file_picker_darwin", + "version": "2.4.1", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_darwin", + "license_file": "third_party/licenses/dart/file_picker_darwin.txt" + }, + { + "name": "file_picker_linux", + "version": "2.0.2", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_linux", + "license_file": "third_party/licenses/dart/file_picker_linux.txt" + }, + { + "name": "file_picker_platform_interface", + "version": "4.2.0", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_platform_interface", + "license_file": "third_party/licenses/dart/file_picker_platform_interface.txt" + }, + { + "name": "file_picker_web", + "version": "4.2.0", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_web", + "license_file": "third_party/licenses/dart/file_picker_web.txt" + }, + { + "name": "fixnum", + "version": "1.1.1", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/fixnum", + "license_file": "third_party/licenses/dart/fixnum.txt" + }, + { + "name": "flutter", + "version": "Flutter SDK 3.47.9", + "upstream": "https://flutter.dev", + "license_file": "third_party/licenses/dart/flutter.txt" + }, + { + "name": "flutter_lints", + "version": "6.0.0", + "upstream": "https://github.com/flutter/packages/tree/main/packages/flutter_lints", + "license_file": "third_party/licenses/dart/flutter_lints.txt" + }, + { + "name": "flutter_localizations", + "version": "Flutter SDK 3.47.9", + "upstream": "https://github.com/flutter/flutter", + "license_file": "third_party/licenses/dart/flutter_localizations.txt" + }, + { + "name": "flutter_test", + "version": "Flutter SDK 3.47.9", + "upstream": "https://github.com/flutter/flutter", + "license_file": "third_party/licenses/dart/flutter_test.txt" + }, + { + "name": "flutter_web_plugins", + "version": "Flutter SDK 3.47.9", + "upstream": "https://flutter.dev", + "license_file": "third_party/licenses/dart/flutter_web_plugins.txt" + }, + { + "name": "hooks", + "version": "2.0.2", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/hooks", + "license_file": "third_party/licenses/dart/hooks.txt" + }, + { + "name": "http", + "version": "1.6.0", + "upstream": "https://github.com/dart-lang/http/tree/master/pkgs/http", + "license_file": "third_party/licenses/dart/http.txt" + }, + { + "name": "http_parser", + "version": "4.1.2", + "upstream": "https://github.com/dart-lang/http/tree/master/pkgs/http_parser", + "license_file": "third_party/licenses/dart/http_parser.txt" + }, + { + "name": "image", + "version": "4.12.0", + "upstream": "https://github.com/brendan-duncan/image", + "license_file": "third_party/licenses/dart/image.txt" + }, + { + "name": "intl", + "version": "0.20.3 # This should follow semver to not break Flutter, where it is unpinned, see https://github.com/flutter/flutter/blob/main/pubspec.yaml", + "upstream": "https://github.com/dart-lang/i18n/tree/main/pkgs/intl", + "license_file": "third_party/licenses/dart/intl.txt" + }, + { + "name": "jni", + "version": "1.1.1", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/jni", + "license_file": "third_party/licenses/dart/jni.txt" + }, + { + "name": "jni_flutter", + "version": "1.0.4+1", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/jni_flutter", + "license_file": "third_party/licenses/dart/jni_flutter.txt" + }, + { + "name": "jni_util", + "version": "1.0.0", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/jni_util", + "license_file": "third_party/licenses/dart/jni_util.txt" + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "upstream": "https://github.com/dart-lang/leak_tracker/tree/main/pkgs/leak_tracker", + "license_file": "third_party/licenses/dart/leak_tracker.txt" + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "upstream": "https://github.com/dart-lang/leak_tracker/tree/main/pkgs/leak_tracker_flutter_testing", + "license_file": "third_party/licenses/dart/leak_tracker_flutter_testing.txt" + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "upstream": "https://github.com/dart-lang/leak_tracker/tree/main/pkgs/leak_tracker_testing", + "license_file": "third_party/licenses/dart/leak_tracker_testing.txt" + }, + { + "name": "lints", + "version": "6.1.0", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/lints", + "license_file": "third_party/licenses/dart/lints.txt" + }, + { + "name": "logging", + "version": "1.3.0", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/logging", + "license_file": "third_party/licenses/dart/logging.txt" + }, + { + "name": "matcher", + "version": "0.12.20", + "upstream": "https://github.com/dart-lang/test/tree/master/pkgs/matcher", + "license_file": "third_party/licenses/dart/matcher.txt" + }, + { + "name": "material_color_utilities", + "version": "0.13.0", + "upstream": "https://github.com/material-foundation/material-color-utilities/tree/main/dart", + "license_file": "third_party/licenses/dart/material_color_utilities.txt" + }, + { + "name": "media_kit", + "version": "1.2.6", + "upstream": "https://github.com/media-kit/media-kit", + "license_file": "third_party/licenses/dart/media_kit.txt" + }, + { + "name": "media_kit_libs_android_video", + "version": "1.3.8", + "upstream": "https://github.com/media-kit/media-kit.git", + "license_file": "third_party/licenses/dart/media_kit_libs_android_video.txt" + }, + { + "name": "media_kit_video", + "version": "2.0.1", + "upstream": "https://github.com/media-kit/media-kit", + "license_file": "third_party/licenses/dart/media_kit_video.txt" + }, + { + "name": "meta", + "version": "1.19.0", + "upstream": "https://github.com/dart-lang/sdk/tree/main/pkg/meta", + "license_file": "third_party/licenses/dart/meta.txt" + }, + { + "name": "mime", + "version": "2.1.0", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/mime", + "license_file": "third_party/licenses/dart/mime.txt" + }, + { + "name": "nm", + "version": "0.6.0", + "upstream": "https://github.com/canonical/nm.dart", + "license_file": "third_party/licenses/dart/nm.txt" + }, + { + "name": "objective_c", + "version": "9.5.0", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/objective_c", + "license_file": "third_party/licenses/dart/objective_c.txt" + }, + { + "name": "package_config", + "version": "3.0.0", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/package_config", + "license_file": "third_party/licenses/dart/package_config.txt" + }, + { + "name": "package_info_plus", + "version": "10.2.2", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/package_info_plus", + "license_file": "third_party/licenses/dart/package_info_plus.txt" + }, + { + "name": "package_info_plus_platform_interface", + "version": "4.1.0", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/", + "license_file": "third_party/licenses/dart/package_info_plus_platform_interface.txt" + }, + { + "name": "path", + "version": "1.9.1", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/path", + "license_file": "third_party/licenses/dart/path.txt" + }, + { + "name": "path_provider", + "version": "2.1.6", + "upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider", + "license_file": "third_party/licenses/dart/path_provider.txt" + }, + { + "name": "path_provider_android", + "version": "2.3.1", + "upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_android", + "license_file": "third_party/licenses/dart/path_provider_android.txt" + }, + { + "name": "path_provider_foundation", + "version": "2.6.0", + "upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_foundation", + "license_file": "third_party/licenses/dart/path_provider_foundation.txt" + }, + { + "name": "path_provider_linux", + "version": "2.2.2", + "upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_linux", + "license_file": "third_party/licenses/dart/path_provider_linux.txt" + }, + { + "name": "path_provider_platform_interface", + "version": "2.1.3", + "upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_platform_interface", + "license_file": "third_party/licenses/dart/path_provider_platform_interface.txt" + }, + { + "name": "path_provider_windows", + "version": "2.3.0", + "upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_windows", + "license_file": "third_party/licenses/dart/path_provider_windows.txt" + }, + { + "name": "petitparser", + "version": "7.1.0", + "upstream": "https://github.com/petitparser/dart-petitparser", + "license_file": "third_party/licenses/dart/petitparser.txt" + }, + { + "name": "platform", + "version": "3.2.0", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/platform", + "license_file": "third_party/licenses/dart/platform.txt" + }, + { + "name": "plugin_platform_interface", + "version": "2.1.8", + "upstream": "https://github.com/flutter/packages/tree/main/packages/plugin_platform_interface", + "license_file": "third_party/licenses/dart/plugin_platform_interface.txt" + }, + { + "name": "posix", + "version": "6.5.4", + "upstream": "https://github.com/onepub-dev/dart_posix", + "license_file": "third_party/licenses/dart/posix.txt" + }, + { + "name": "pub_semver", + "version": "2.2.1", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/pub_semver", + "license_file": "third_party/licenses/dart/pub_semver.txt" + }, + { + "name": "record_use", + "version": "0.6.0", + "upstream": "https://github.com/dart-lang/native/tree/main/pkgs/record_use", + "license_file": "third_party/licenses/dart/record_use.txt" + }, + { + "name": "safe_local_storage", + "version": "2.0.7", + "upstream": "https://github.com/alexmercerind/safe_local_storage.git", + "license_file": "third_party/licenses/dart/safe_local_storage.txt" + }, + { + "name": "share_plus", + "version": "13.3.1", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/share_plus/share_plus", + "license_file": "third_party/licenses/dart/share_plus.txt" + }, + { + "name": "share_plus_platform_interface", + "version": "7.2.0", + "upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/", + "license_file": "third_party/licenses/dart/share_plus_platform_interface.txt" + }, + { + "name": "shared_preferences", + "version": "2.5.6", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences", + "license_file": "third_party/licenses/dart/shared_preferences.txt" + }, + { + "name": "shared_preferences_android", + "version": "2.5.4", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_android", + "license_file": "third_party/licenses/dart/shared_preferences_android.txt" + }, + { + "name": "shared_preferences_foundation", + "version": "2.5.7", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_foundation", + "license_file": "third_party/licenses/dart/shared_preferences_foundation.txt" + }, + { + "name": "shared_preferences_linux", + "version": "2.4.1", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_linux", + "license_file": "third_party/licenses/dart/shared_preferences_linux.txt" + }, + { + "name": "shared_preferences_platform_interface", + "version": "2.4.2", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_platform_interface", + "license_file": "third_party/licenses/dart/shared_preferences_platform_interface.txt" + }, + { + "name": "shared_preferences_web", + "version": "2.4.3", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_web", + "license_file": "third_party/licenses/dart/shared_preferences_web.txt" + }, + { + "name": "shared_preferences_windows", + "version": "2.4.1", + "upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_windows", + "license_file": "third_party/licenses/dart/shared_preferences_windows.txt" + }, + { + "name": "sky_engine", + "version": "Flutter SDK 3.47.9", + "upstream": "https://github.com/flutter/flutter", + "license_file": "third_party/licenses/dart/sky_engine.txt" + }, + { + "name": "source_span", + "version": "1.10.2", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/source_span", + "license_file": "third_party/licenses/dart/source_span.txt" + }, + { + "name": "stack_trace", + "version": "1.12.2", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/stack_trace", + "license_file": "third_party/licenses/dart/stack_trace.txt" + }, + { + "name": "stream_channel", + "version": "2.1.4", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/stream_channel", + "license_file": "third_party/licenses/dart/stream_channel.txt" + }, + { + "name": "string_scanner", + "version": "1.4.1", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/string_scanner", + "license_file": "third_party/licenses/dart/string_scanner.txt" + }, + { + "name": "synchronized", + "version": "3.5.0", + "upstream": "https://github.com/tekartik/synchronized.dart/tree/master/synchronized", + "license_file": "third_party/licenses/dart/synchronized.txt" + }, + { + "name": "term_glyph", + "version": "1.2.2", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/term_glyph", + "license_file": "third_party/licenses/dart/term_glyph.txt" + }, + { + "name": "test_api", + "version": "0.7.12", + "upstream": "https://github.com/dart-lang/test/tree/master/pkgs/test_api", + "license_file": "third_party/licenses/dart/test_api.txt" + }, + { + "name": "typed_data", + "version": "1.4.0", + "upstream": "https://github.com/dart-lang/core/tree/main/pkgs/typed_data", + "license_file": "third_party/licenses/dart/typed_data.txt" + }, + { + "name": "universal_platform", + "version": "1.1.0", + "upstream": "https://github.com/gskinnerTeam/flutter-universal-platform", + "license_file": "third_party/licenses/dart/universal_platform.txt" + }, + { + "name": "uri_parser", + "version": "3.0.2", + "upstream": "https://github.com/alexmercerind/uri_parser", + "license_file": "third_party/licenses/dart/uri_parser.txt" + }, + { + "name": "url_launcher", + "version": "6.3.3", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher", + "license_file": "third_party/licenses/dart/url_launcher.txt" + }, + { + "name": "url_launcher_android", + "version": "6.3.33", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android", + "license_file": "third_party/licenses/dart/url_launcher_android.txt" + }, + { + "name": "url_launcher_ios", + "version": "6.4.2", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_ios", + "license_file": "third_party/licenses/dart/url_launcher_ios.txt" + }, + { + "name": "url_launcher_linux", + "version": "3.2.3", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_linux", + "license_file": "third_party/licenses/dart/url_launcher_linux.txt" + }, + { + "name": "url_launcher_macos", + "version": "3.2.6", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_macos", + "license_file": "third_party/licenses/dart/url_launcher_macos.txt" + }, + { + "name": "url_launcher_platform_interface", + "version": "2.3.2", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_platform_interface", + "license_file": "third_party/licenses/dart/url_launcher_platform_interface.txt" + }, + { + "name": "url_launcher_web", + "version": "2.4.3", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_web", + "license_file": "third_party/licenses/dart/url_launcher_web.txt" + }, + { + "name": "url_launcher_windows", + "version": "3.1.6", + "upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_windows", + "license_file": "third_party/licenses/dart/url_launcher_windows.txt" + }, + { + "name": "uuid", + "version": "4.6.0", + "upstream": "https://github.com/Daegalus/dart-uuid", + "license_file": "third_party/licenses/dart/uuid.txt" + }, + { + "name": "vector_math", + "version": "2.4.0", + "upstream": "https://github.com/flutter/core-packages/tree/main/packages/vector_math", + "license_file": "third_party/licenses/dart/vector_math.txt" + }, + { + "name": "vm_service", + "version": "15.4.0", + "upstream": "https://github.com/dart-lang/sdk/tree/main/pkg/vm_service", + "license_file": "third_party/licenses/dart/vm_service.txt" + }, + { + "name": "wakelock_plus", + "version": "1.8.2", + "upstream": "https://github.com/fluttercommunity/wakelock_plus/tree/main/wakelock_plus", + "license_file": "third_party/licenses/dart/wakelock_plus.txt" + }, + { + "name": "wakelock_plus_platform_interface", + "version": "1.7.0", + "upstream": ">-2", + "license_file": "third_party/licenses/dart/wakelock_plus_platform_interface.txt" + }, + { + "name": "web", + "version": "1.1.2", + "upstream": "https://github.com/dart-lang/web", + "license_file": "third_party/licenses/dart/web.txt" + }, + { + "name": "win32", + "version": "6.4.0", + "upstream": "https://github.com/halildurmus/win32", + "license_file": "third_party/licenses/dart/win32.txt" + }, + { + "name": "windows_file_picker", + "version": "2.0.1", + "upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_windows", + "license_file": "third_party/licenses/dart/windows_file_picker.txt" + }, + { + "name": "xdg_directories", + "version": "1.1.0", + "upstream": "https://github.com/flutter/packages/tree/main/packages/xdg_directories", + "license_file": "third_party/licenses/dart/xdg_directories.txt" + }, + { + "name": "xml", + "version": "7.1.0", + "upstream": "https://github.com/renggli/dart-xml", + "license_file": "third_party/licenses/dart/xml.txt" + }, + { + "name": "yaml", + "version": "3.1.4", + "upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/yaml", + "license_file": "third_party/licenses/dart/yaml.txt" + } + ] +} diff --git a/third_party/background_downloader/LICENSE b/third_party/background_downloader/LICENSE new file mode 100644 index 0000000..b87579b --- /dev/null +++ b/third_party/background_downloader/LICENSE @@ -0,0 +1,37 @@ +Copyright 2022 BBFlight LLC + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +This software includes a modified version of package localstore (https://pub.dev/packages/localstore/versions/1.3.5). The license for that software is: + +MIT License + +Copyright (c) 2021 Hanoi University of Mining and Geology, Vietnam. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/background_downloader/NOTICE.txt b/third_party/background_downloader/NOTICE.txt new file mode 100644 index 0000000..034b8f4 --- /dev/null +++ b/third_party/background_downloader/NOTICE.txt @@ -0,0 +1,63 @@ +# background_downloader 9.6.5 — RM003 patch + +This is a runtime-only vendoring of the exact published `background_downloader` +9.6.5 package, not a general dependency upgrade. `lib/`, `android/`, `ios/`, +`pubspec.yaml`, and `LICENSE` were copied from that pinned package. Examples, +upstream tests, and documentation are omitted to keep the fork small. + +- Upstream: https://github.com/781flyingdutchman/background_downloader +- Published archive: https://pub.dev/api/archives/background_downloader-9.6.5.tar.gz +- Published archive SHA-256 (original application lockfile and pub cache): + `4df9b2a2d5b78b883ebd4b6338cbc1b541ea3482da24112339468a66a7492b43` +- Upstream `LICENSE` is retained verbatim, including its BSD-3-Clause terms and + the bundled localstore MIT notice. Preserve these notices in redistribution. +- Package version remains `9.6.5`; the root application's path override selects + this reviewed copy. + +## Only production changes + +1. `lib/src/chunk.dart`: `resumeChunkTasks` resumes only chunks whose serialized + status is not `TaskStatus.complete`. A completed chunk's final file and status + must remain available to the real parent stitching worker. Completed chunks + have no resume data; trying to resume them returns false and cancels the parent. +2. `lib/src/desktop/desktop_downloader.dart`: read and retain the parent's resume + snapshot before enqueue. Enqueue starts a desktop worker which consumes that + record; rereading after enqueue races the worker and can return null. This + separate race was reproduced while exercising the actual Linux workers. + +## Download crash recovery fixes (0.4.0) + +3. `lib/src/permissions.dart`: retain the current request's completer locally; + a permission callback may arrive before the request method returns. Release the + shared in-flight slot in `finally`, including dispatch failures. +4. `lib/src/base_downloader.dart`: propagate initialization failures to `ready`, + and mark readiness only after subclass/background-handler setup completes. + Await initial connectivity so errors are owned by initialization. +5. `lib/src/base_downloader.dart`: the database update queue reports write errors + through its flush barrier, including failed-then-successful batches, while + allowing later recovery. Detached futures no longer leak these errors. + +The 0.4.0 fixes above did not modify native downloader sources. Dart regressions for +those paths are in `test/download_android_*_test.dart`. They reproduce callback, +initialization, connectivity and persistence failures on the host; they do not +claim an Android process crash was reproduced without logcat/device evidence. + +## In-app transfer fixes (0.4.2) + +6. `lib/src/native_downloader.dart`: `enqueueChild` now awaits actual native + enqueue and acknowledges its Boolean result. Rejection or a dispatch exception + returns false, allowing the parent to fail instead of waiting forever for a + nonexistent child. Background-channel tests cover rejection, a pending native + acknowledgement and an exception without detached errors. +7. Android `BDPlugin.kt` and `DownloadNetworkPolicy.java`: on API 28+, both + WorkManager and user-initiated JobScheduler jobs use the same explicit INTERNET + network request, without VALIDATED or NOT_VPN. Wi-Fi transport remains required + when the effective task policy is Wi-Fi-only. This avoids making successful + external OS connectivity validation a prerequisite for a reachable media host. + Older Android versions retain their original compatible scheduler constraints. + +Application-side changes are outside this fork: multipart is selected only after +the server advertises the HEAD/length/range capabilities the native worker needs; +otherwise a normal streaming GET is used. A manual retry of failed multipart work +also uses a single stream and restores its ordinary retry budget. + diff --git a/third_party/background_downloader/android/build.gradle b/third_party/background_downloader/android/build.gradle new file mode 100644 index 0000000..d8b1f70 --- /dev/null +++ b/third_party/background_downloader/android/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.20' +} + +def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0] as int +def isBuiltInKotlinFalse = project.hasProperty('builtInKotlin') && (project.property('builtInKotlin') == false || project.property('builtInKotlin') == 'false') +if (agpMajor < 9 || isBuiltInKotlinFalse) { + apply plugin: ['kotlin', 'android'].join('-') +} + +group = 'com.bbflight.background_downloader' +version = '1.0-SNAPSHOT' + +allprojects { + repositories { + google() + mavenCentral() + } +} + +android { + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdk = 23 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + if (project.android.hasProperty("namespace")) { namespace = 'com.bbflight.background_downloader' } + + +} + +dependencies { + implementation "androidx.work:work-runtime-ktx:2.11.0" + implementation "androidx.concurrent:concurrent-futures-ktx:1.3.0" + implementation "androidx.preference:preference-ktx:1.2.1" + implementation "androidx.core:core-ktx:1.17.0" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" + testImplementation "junit:junit:4.13.2" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2" + androidTestImplementation "androidx.test.ext:junit:1.3.0" + androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0" + +} +repositories { + mavenCentral() +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} diff --git a/third_party/background_downloader/android/gradle.properties b/third_party/background_downloader/android/gradle.properties new file mode 100644 index 0000000..048c61a --- /dev/null +++ b/third_party/background_downloader/android/gradle.properties @@ -0,0 +1,15 @@ +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +#Fri Dec 19 20:02:00 PST 2025 +android.enableJetifier=true +android.useAndroidX=true diff --git a/third_party/background_downloader/android/settings.gradle b/third_party/background_downloader/android/settings.gradle new file mode 100644 index 0000000..2b7ee25 --- /dev/null +++ b/third_party/background_downloader/android/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + plugins { + id 'com.android.library' version '9.0.1' + id 'org.jetbrains.kotlin.android' version '2.3.20' + } +} + +rootProject.name = 'background_downloader' diff --git a/third_party/background_downloader/android/src/androidTest/kotlin/UriUtilsTest.kt b/third_party/background_downloader/android/src/androidTest/kotlin/UriUtilsTest.kt new file mode 100644 index 0000000..219e43e --- /dev/null +++ b/third_party/background_downloader/android/src/androidTest/kotlin/UriUtilsTest.kt @@ -0,0 +1,115 @@ +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bbflight.background_downloader.UriUtils +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UriUtilsTest { + + @Test + fun pack_should_pack_filename_and_uri_into_a_single_string() { + val filename = "myFile.txt" + val uri = Uri.parse("content://com.example.app/document/123") + + val packedString = UriUtils.pack(filename, uri) + + assertEquals(":::$filename::::::$uri:::", packedString) + } + + @Test + fun unpack_should_unpack_a_valid_packed_string_into_filename_and_uri() { + val filename = "myFile.txt" + val uri = Uri.parse("content://com.example.app/document/123") + val packedString = ":::$filename::::::$uri:::" + + val (unpackedFilename, unpackedUri) = UriUtils.unpack(packedString) + + assertEquals(filename, unpackedFilename) + assertEquals(uri, unpackedUri) + } + + @Test + fun unpack_should_return_original_string_and_null_uri_for_simple_filename_string() { + val invalidPackedString = "This is not a packed string" + + val (filename, uri) = UriUtils.unpack(invalidPackedString) + + assertEquals(invalidPackedString, filename) + assertNull(uri) + } + + @Test + fun unpack_should_return_null_and_uri_for_simple_uri_string() { + val uriString = "https://www.example.com/path/to/resource" + + val (filename, uri) = UriUtils.unpack(uriString) + + assertNull(filename) + assertEquals(uri.toString(), uriString) + } + + @Test + fun uriFromStringValue_should_return_Uri_for_a_valid_Uri_string() { + val uriString = "https://www.example.com/path/to/resource" + val expectedUri = Uri.parse(uriString) + + val resultUri = UriUtils.uriFromStringValue(uriString) + + assertEquals(expectedUri, resultUri) + } + + @Test + fun uriFromStringValue_should_return_Uri_from_a_valid_packed_string() { + val filename = "myFile.txt" + val uri = Uri.parse("content://com.example.app/document/123") + val packedString = UriUtils.pack(filename, uri) + + val resultUri = UriUtils.uriFromStringValue(packedString) + + assertEquals(uri, resultUri) + } + + @Test + fun uriFromStringValue_should_return_null_for_an_invalid_string() { + val invalidString = "This is not a Uri or packed string" + + val resultUri = UriUtils.uriFromStringValue(invalidString) + + assertNull(resultUri) + } + + @Test + fun uriFromStringValue_should_return_null_for_a_packed_string_with_invalid_Uri() { + val filename = "myFile.txt" + val invalidUri = "invalid" + val packedString = ":::$filename::::::$invalidUri:::" + + val resultUri = UriUtils.uriFromStringValue(packedString) + + assertNull(resultUri) + } + + @Test + fun containsUri_should_return_true_for_a_valid_Uri_string() { + val uriString = "https://www.example.com/path/to/resource" + assertTrue(UriUtils.containsUri(uriString)) + } + + @Test + fun containsUri_should_return_true_for_a_valid_packed_Uri_string() { + val filename = "myFile.txt" + val uri = Uri.parse("content://com.example.app/document/123") + val packedString = UriUtils.pack(filename, uri) + + assertTrue(UriUtils.containsUri(packedString)) + } + + @Test + fun containsUri_should_return_false_for_an_invalid_string() { + val invalidString = "This is not a Uri or packed string" + + assertFalse(UriUtils.containsUri(invalidString)) + } +} \ No newline at end of file diff --git a/third_party/background_downloader/android/src/main/AndroidManifest.xml b/third_party/background_downloader/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..63b9d47 --- /dev/null +++ b/third_party/background_downloader/android/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Auth.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Auth.kt new file mode 100644 index 0000000..535ddb7 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Auth.kt @@ -0,0 +1,94 @@ +package com.bbflight.background_downloader + +import android.net.Uri +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable + +@Serializable +@Suppress("unused") +@OptIn(InternalSerializationApi::class) +class Auth( + private var accessToken: String? = null, + private var accessHeaders: Map = emptyMap(), + private var accessQueryParams: Map = emptyMap(), + private var accessTokenExpiryTime: Long? = null, + private var refreshToken: String? = null, + private var refreshHeaders: Map = emptyMap(), + private var refreshQueryParams: Map = emptyMap(), + private var refreshUrl: String? = null, + private var onAuthRawHandle: Long? = null +) { + + /** + * Returns the headers specified for the access request, with the + * template `{accessToken}` and `{refreshToken}` replaced. + */ + fun getExpandedAccessHeaders(): Map = expandMap(accessHeaders) + + /** + * Returns the query parameters specified for the access request, with the + * template `{accessToken}` and `{refreshToken}` replaced. + */ + fun getExpandedAccessQueryParams(): Map = expandMap(accessQueryParams) + + /** + * Add/update the query parameters in this [url] with [queryParams] + * and return the new uri + */ + fun addOrUpdateQueryParams( + url: String, + queryParams: Map = emptyMap() + ): Uri { + val startUri = Uri.parse(url) + if (queryParams.isEmpty()) { + return startUri + } + val updatedQueryParams = + startUri.queryParameterNames.associateWith { startUri.getQueryParameter(it) } + .toMutableMap() + updatedQueryParams.putAll(queryParams) + return startUri.buildUpon().clearQuery().apply { + updatedQueryParams.forEach { (key, value) -> + appendQueryParameter(key, value) + } + }.build() + } + + + /** + * Returns true if the [accessTokenExpiryTime is after now plus + * the [bufferTime], otherwise returns false + */ + fun isTokenExpired(bufferTime: Long = 10000): Boolean { + val expiry = accessTokenExpiryTime ?: return false + val expiryTimeWithBuffer = System.currentTimeMillis() + bufferTime + return expiryTimeWithBuffer > expiry + } + + /** + * Expands the [mapToExpand] by replacing {accessToken} and {refreshToken} + * + * Returns the expanded map, without changing the original + */ + private fun expandMap(mapToExpand: Map): Map { + val newMap = mutableMapOf() + mapToExpand.forEach { (key, value) -> + var newValue = value + if (accessToken != null) { + newValue = newValue.replace("{accessToken}", accessToken!!) + } + if (refreshToken != null) { + newValue = newValue.replace("{refreshToken}", refreshToken!!) + } + newMap[key] = newValue + } + return newMap + } + + /** + * True if this Auth object has an 'onAuth' callback that can be called to refresh the + * access token + */ + fun hasOnAuthCallback(): Boolean = onAuthRawHandle != null + +} \ No newline at end of file diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/BDPlugin.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/BDPlugin.kt new file mode 100644 index 0000000..7be3708 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/BDPlugin.kt @@ -0,0 +1,1770 @@ +package com.bbflight.background_downloader + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import android.os.PersistableBundle +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import com.bbflight.background_downloader.BDPlugin.Companion.backgroundChannel +import com.bbflight.background_downloader.TaskWorker.Companion.processStatusUpdate +import com.bbflight.background_downloader.TaskWorker.Companion.taskToJsonString +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.lang.Long.min +import java.net.MalformedURLException +import java.net.URL +import java.net.URLDecoder +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write +import androidx.work.OneTimeWorkRequest + + +/** + * Custom exception for method call errors + */ +class MethodCallException(val errorCode: String, val errorMessage: String?, val errorDetails: Any?) : + Exception(errorMessage) + +/** + * Entry-point for Android native side of the plugin + * + * Manages the WorkManager task queue and the interface to Dart. Actual work is done in + * [TaskWorker] + */ +@Suppress("ConstPropertyName") +class BDPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, + PluginRegistry.ActivityResultListener, + PluginRegistry.RequestPermissionsResultListener { + companion object { + const val TAG = "BackgroundDownloader" + const val keyTasksMap = "com.bbflight.background_downloader.taskMap.v2" + const val keyResumeDataMap = "com.bbflight.background_downloader.resumeDataMap.v2" + const val keyStatusUpdateMap = "com.bbflight.background_downloader.statusUpdateMap.v2" + const val keyProgressUpdateMap = "com.bbflight.background_downloader.progressUpdateMap.v2" + const val keyRequireWiFi = + "com.bbflight.background_downloader.requireWifi" + const val keyCallbackDispatcherRawHandle = + "com.bbflight.background_downloader.callbackDispatcherRawHandle" + const val keyConfigForegroundFileSize = + "com.bbflight.background_downloader.config.foregroundFileSize" + const val keyConfigProxyAddress = "com.bbflight.background_downloader.config.proxyAddress" + const val keyConfigProxyPort = "com.bbflight.background_downloader.config.proxyPort" + const val keyConfigRequestTimeout = + "com.bbflight.background_downloader.config.requestTimeout" + const val keyConfigCheckAvailableSpace = + "com.bbflight.background_downloader.config.checkAvailableSpace" + const val keyConfigUseCacheDir = "com.bbflight.background_downloader.config.useCacheDir" + const val keyConfigUseExternalStorage = + "com.bbflight.background_downloader.config.useExternalStorage" + const val keyConfigSkipExistingFiles = + "com.bbflight.background_downloader.config.skipExistingFiles" + const val keyConfigTempFilePath = + "com.bbflight.background_downloader.config.tempFilePath" + + + @SuppressLint("StaticFieldLeak") + var notificationButtonText = + Collections.synchronizedMap(mutableMapOf()) // for localization + var firstBackgroundChannel: MethodChannel? = null + var bgChannelByTaskId = Collections.synchronizedMap(mutableMapOf()) + var flutterEngineByTaskId = + Collections.synchronizedMap(mutableMapOf()) + var requireWifi = RequireWiFi.asSetByTask // global setting + val localResumeData = + Collections.synchronizedMap(mutableMapOf()) // by taskId, for pause notifications + // MutableMap, so contains checks keys: ConcurrentHashMap.contains checks values + val cancelUpdateSentForTaskId: MutableMap = + ConcurrentHashMap() // + val pausedTaskIds = + Collections.synchronizedSet(mutableSetOf()) // , acts as flag + val canceledTaskIds = + Collections.synchronizedSet(mutableSetOf()) // , acts as flag + val parallelDownloadTaskWorkers = + Collections.synchronizedMap(HashMap()) + val tasksToReEnqueue = + Collections.synchronizedSet(mutableSetOf()) // for when WiFi requirement changes + val slotReleasedRunIds: MutableSet = + ConcurrentHashMap.newKeySet() // of canceled runs whose holding queue slot was released + val startedRunIds: MutableSet = + ConcurrentHashMap.newKeySet() // of runs that release their own slot when they end + val notificationConfigJsonStrings = + Collections.synchronizedMap(mutableMapOf()) // by taskId + var forceFailPostOnBackgroundChannel = false + val prefsLock = ReentrantReadWriteLock() + val remainingBytesToDownload = + Collections.synchronizedMap(mutableMapOf()) // + var haveLoggedProxyMessage = false + var holdingQueue: HoldingQueue? = null + + /** + * Enqueue a WorkManager task based on the provided parameters + */ + suspend fun doEnqueue( + context: Context, + task: Task, + notificationConfigJsonString: String?, + resumeData: ResumeData?, + initialDelayMillis: Long = 0, + plugin: BDPlugin? = null + ): Boolean { + val expedited = task.priority < 5 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val actualDelayMillis = if (initialDelayMillis > 0 && expedited) { + kotlinx.coroutines.delay(initialDelayMillis) + 0L + } else { + initialDelayMillis + } + + Log.i(TAG, "Enqueuing task with id ${task.taskId}") + // store backgroundChannel to be used by this task + val bgChannel = backgroundChannel(plugin) + if (bgChannel != null) { + bgChannelByTaskId[task.taskId] = bgChannel + } else { + Log.w(TAG, "Could not find backgroundChannel for taskId ${task.taskId}") + } + holdingQueue?.hostByTaskId?.set( + task.taskId, + task.host() + ) // store host if we have a HoldingQueue + canceledTaskIds.remove(task.taskId) // reset flag + cancelUpdateSentForTaskId.remove(task.taskId) // reset flag + + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val runInForegroundFileSize = prefs.getInt(keyConfigForegroundFileSize, -1) + // UIDT (JobScheduler) is used only if task priority is 0 (max priority) + // and notificationConfigJsonString is set (required for foreground) + // and API level is >= 34 (Upside Down Cake) + var useJobScheduler = task.priority == 0 && + notificationConfigJsonString != null && + Build.VERSION.SDK_INT >= 34 + if (useJobScheduler) { + // UIDT requires the RUN_USER_INITIATED_JOBS permission. If not granted, fallback to WorkManager. + val permissionStatus = ContextCompat.checkSelfPermission(context, Manifest.permission.RUN_USER_INITIATED_JOBS) + if (permissionStatus != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "RUN_USER_INITIATED_JOBS permission not granted, falling back to WorkManager for task ${task.taskId}") + useJobScheduler = false + } + } + + val taskRequiresWifi = taskRequiresWifi(task) + + var success = false + if (useJobScheduler) { + try { + val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val componentName = ComponentName(context, UIDTJobService::class.java) + val extras = PersistableBundle().apply { + putString(TaskWorker.keyTask, taskToJsonString(task)) + if (notificationConfigJsonString != null) { + putString(TaskWorker.keyNotificationConfig, notificationConfigJsonString) + } + if (resumeData != null) { + putString(TaskWorker.keyResumeDataData, resumeData.data) + putLong(TaskWorker.keyStartByte, resumeData.requiredStartByte) + putString(TaskWorker.keyETag, resumeData.eTag) + } + } + + val jobInfoBuilder = JobInfo.Builder(task.taskId.hashCode(), componentName) + .setRequiresCharging(false) + .setExtras(extras) + + if (Build.VERSION.SDK_INT >= 28) { + jobInfoBuilder.setRequiredNetwork(DownloadNetworkPolicy.request(taskRequiresWifi)) + } else if (taskRequiresWifi) { + jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED) + } else { + jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + } + + if (Build.VERSION.SDK_INT >= 34) { + jobInfoBuilder.setUserInitiated(true) + // Provide a default estimate if actual size is unknown to help OS scheduling + jobInfoBuilder.setEstimatedNetworkBytes(1024L * 1024L * 10L, JobInfo.NETWORK_BYTES_UNKNOWN.toLong()) + } + + if (actualDelayMillis > 0L && Build.VERSION.SDK_INT < 34) { + jobInfoBuilder.setMinimumLatency(actualDelayMillis) + } + + val result = jobScheduler.schedule(jobInfoBuilder.build()) + success = (result == JobScheduler.RESULT_SUCCESS) + if (!success) { + Log.w(TAG, "Unable to schedule JobScheduler job for taskId ${task.taskId}, falling back to WorkManager") + useJobScheduler = false + } + } catch (e: Exception) { + Log.w(TAG, "Exception scheduling JobScheduler job for taskId ${task.taskId}, falling back to WorkManager", e) + useJobScheduler = false + } + } + if (!useJobScheduler) { + // Use WorkManager + val dataBuilder = Data.Builder().putString(TaskWorker.keyTask, taskToJsonString(task)) + if (notificationConfigJsonString != null) { + dataBuilder.putString( + TaskWorker.keyNotificationConfig, + notificationConfigJsonString + ) + notificationConfigJsonStrings[task.taskId] = notificationConfigJsonString + } + if (resumeData != null) { + dataBuilder.putString(TaskWorker.keyResumeDataData, resumeData.data) + .putLong(TaskWorker.keyStartByte, resumeData.requiredStartByte) + .putString(TaskWorker.keyETag, resumeData.eTag) + } + val data = dataBuilder.build() + + val constraints = Constraints.Builder().apply { + if (Build.VERSION.SDK_INT >= 28) { + setRequiredNetworkRequest( + DownloadNetworkPolicy.request(taskRequiresWifi), + if (taskRequiresWifi) NetworkType.UNMETERED else NetworkType.CONNECTED + ) + } else if (taskRequiresWifi) { + setRequiredNetworkType(NetworkType.UNMETERED) + } else { + setRequiredNetworkType(NetworkType.CONNECTED) + } + }.build() + val requestBuilder: OneTimeWorkRequest.Builder = + createRequestBuilder(task, data, constraints) ?: return false + if (actualDelayMillis != 0L) { + requestBuilder.setInitialDelay(actualDelayMillis, TimeUnit.MILLISECONDS) + } + + if (expedited) { + requestBuilder.setExpedited(policy = OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + val workManager = WorkManager.getInstance(context) + success = try { + val operation = try { + workManager.enqueue(requestBuilder.build()) + } catch (e: Exception) { + if (expedited) { + Log.i(TAG, "Could not enqueue expedited task, falling back to non-expedited") + val nonExpeditedRequestBuilder = + createRequestBuilder(task, data, constraints) ?: throw e + if (actualDelayMillis != 0L) { + nonExpeditedRequestBuilder.setInitialDelay( + actualDelayMillis, + TimeUnit.MILLISECONDS + ) + } + workManager.enqueue(nonExpeditedRequestBuilder.build()) + } else { + throw e + } + } + operation.result.get() + true + } catch (_: Throwable) { + Log.w( + TAG, + "Unable to start background request for taskId ${task.taskId}" + ) + false + } + } + + if (success) { + if (initialDelayMillis != 0L) { + delay(min(100L, initialDelayMillis)) + } + if (holdingQueue?.enqueuedTaskIds?.contains(task.taskId) != true) + processStatusUpdate(task, TaskStatus.enqueued, prefs, context = context) + + // Register the enqueue with the NotificationService + NotificationService.registerEnqueue( + EnqueueItem( + context = context, + task = task, + notificationConfigJsonString = notificationConfigJsonString + ), success = true + ) + // store Task in persistent storage, as Json representation keyed by taskId + prefsLock.write { + val tasksMap = getTaskMap(prefs) + tasksMap[task.taskId] = task + prefs.edit { + putString(keyTasksMap, bdJson.encodeToString(tasksMap)) + } + } + return true + } + return false + } + + /** True if task requires WiFi, based on global and task-specific settings */ + fun taskRequiresWifi(task: Task): Boolean { + return (requireWifi == RequireWiFi.forAllTasks || (requireWifi == RequireWiFi.asSetByTask && task.requiresWiFi)) + } + + + /** + * Create a [OneTimeWorkRequestBuilder] for the given [task], [data] and [constraints] + * + * Returns the builder, or null if the task type is unknown + */ + private fun createRequestBuilder( + task: Task, + data: Data, + constraints: Constraints + ): OneTimeWorkRequest.Builder? { + val requestBuilder = when (task.taskType) { + "ParallelDownloadTask" -> OneTimeWorkRequestBuilder() + "DownloadTask" -> OneTimeWorkRequestBuilder() + "UriDownloadTask" -> OneTimeWorkRequestBuilder() + "UploadTask" -> OneTimeWorkRequestBuilder() + "UriUploadTask" -> OneTimeWorkRequestBuilder() + "MultiUploadTask" -> OneTimeWorkRequestBuilder() + "DataTask" -> OneTimeWorkRequestBuilder() + else -> { + Log.w(TAG, "Unknown taskType: ${task.taskType}") + return null + } + } + requestBuilder.setInputData(data) + .setConstraints(constraints).addTag(TAG).addTag("taskId=${task.taskId}") + .addTag("group=${task.group}") + return requestBuilder + } + + /** cancel tasks with [taskIds] and return true if successful */ + suspend fun cancelTasksWithIds(context: Context, taskIds: Iterable): Boolean { + val workManager = WorkManager.getInstance(context) + Log.v(TAG, "Canceling taskIds $taskIds") + holdingQueue?.stateMutex?.lock() + val taskIdsRemovedFromHoldingQueue = + holdingQueue?.cancelTasksWithIds(context, taskIds) ?: listOf() + val taskIdsRemaining = taskIds.filter { !taskIdsRemovedFromHoldingQueue.contains(it) } + var success = true + for (taskId in taskIdsRemaining) { + success = success && cancelActiveTaskWithId(context, taskId, workManager) + } + holdingQueue?.stateMutex?.unlock() + return success + } + + /** + * Cancel task with [taskId] and return true if successful + * + * The [taskId] must be managed by the [workManager] + */ + suspend fun cancelActiveTaskWithId( + context: Context, taskId: String, workManager: WorkManager + ): Boolean { + // cancel chunk tasks if this is a ParallelDownloadTask + parallelDownloadTaskWorkers[taskId]?.cancelAllChunkTasks() + + var jobCanceled = false + if (Build.VERSION.SDK_INT >= 34) { + val jobScheduler = + context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + jobScheduler.getPendingJob(taskId.hashCode())?.let { jobInfo -> + val taskJson = jobInfo.extras.getString(TaskWorker.keyTask) + if (taskJson != null) { + try { + val task = bdJson.decodeFromString(taskJson) + if (task.taskId == taskId) { + canceledTaskIds.add(taskId) + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + processStatusUpdate( + task, + TaskStatus.canceled, + prefs, + context = context + ) + holdingQueue?.taskFinished(task) + // remove outstanding notification for task or group + val notificationGroup = + NotificationService.groupNotificationWithTaskId(taskId) + with(NotificationManagerCompat.from(context)) { + if (notificationGroup == null) { + cancel(task.taskId.hashCode()) + } else { + // update notification for group + NotificationService.createUpdateNotificationWorker( + context, + bdJson.encodeToString(task), + bdJson.encodeToString(notificationGroup.notificationConfig), + TaskStatus.canceled.ordinal + ) + } + } + jobScheduler.cancel(jobInfo.id) + jobCanceled = true + } + } catch (e: Exception) { + Log.e(TAG, "Error decoding task from JobScheduler: $e") + } + } + } + } + + // get the workInfos for the task (should be only one) + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfosByTag("taskId=$taskId").get() + } + if (workInfos.isEmpty()) { + if (jobCanceled) return true + Log.d(TAG, "Could not find tasks to cancel") + return false + } + // send cancel update and remove notification + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val tasksMap = getTaskMap(prefs) + for (workInfo in workInfos) { + // skip WorkInfos of earlier runs, whose slot was already released + if (!workInfo.state.isFinished) { + // send cancellation update for tasks that have not yet finished + // and remove associated notification + val task = tasksMap[taskId] + if (task != null) { + canceledTaskIds.add(task.taskId) + val releaseSlot = releaseSlotAtCancel(workInfo.id.toString()) + processStatusUpdate( + task, + TaskStatus.canceled, + prefs, + context = context + ) + if (releaseSlot) { + holdingQueue?.taskFinished(task) + } + // remove outstanding notification for task or group + val notificationGroup = + NotificationService.groupNotificationWithTaskId(taskId) + with(NotificationManagerCompat.from(context)) { + if (notificationGroup == null) { + cancel(task.taskId.hashCode()) + } else { + // update notification for group + NotificationService.createUpdateNotificationWorker( + context, + bdJson.encodeToString(task), + bdJson.encodeToString(notificationGroup.notificationConfig), + TaskStatus.canceled.ordinal + ) + } + } + } else { + Log.d(TAG, "Could not find task with taskId $taskId to cancel") + } + } + val operation = workManager.cancelAllWorkByTag("taskId=$taskId") + try { + withContext(Dispatchers.IO) { + operation.result.get() + } + } catch (_: Throwable) { + Log.w(TAG, "Unable to cancel taskId $taskId in operation: $operation") + return false + } + } + return true + } + + /** + * Returns true if the holding queue slot of the canceled WorkManager run [runId] must be + * released now, because the run hasn't started. A run that has started releases its slot + * when it ends, so its transfer counts towards maxConcurrent until it stops + */ + fun releaseSlotAtCancel(runId: String): Boolean { + if (startedRunIds.contains(runId)) { + return false + } + // the run can still start and finish, and must not then release the slot again, + // which a re-enqueued task may hold by then + slotReleasedRunIds.add(runId) + return true + } + + /** + * Cancel [task] that is not active + * + * Because this [task] is not managed by a [WorkManager] it is cancelled directly. This + * is normally called from a notification when the task is paused (which is why it is + * inactive), and therefore the caller must remove the notification that triggered the + * cancellation. See [NotificationReceiver] + */ + suspend fun cancelInactiveTask(context: Context, task: Task) { + Log.d(TAG, "Canceling inactive task") + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val notificationConfigJsonString = notificationConfigJsonStrings[task.taskId] + if (notificationConfigJsonString != null) { + // update the notification via an UpdateNotificationWorker + NotificationService.createUpdateNotificationWorker( + context, + bdJson.encodeToString(task), + notificationConfigJsonString, + TaskStatus.canceled.ordinal + ) + } + processStatusUpdate(task, TaskStatus.canceled, prefs, context = context) + } + + /** + * Pause the task with this [taskId] + * + * Marks the task for pausing, actual pausing happens in [TaskWorker] + */ + @Suppress("SameReturnValue") + fun pauseTaskWithId(taskId: String): Boolean { + pausedTaskIds.add(taskId) + return true + } + + /** + * Return the backgroundChannel for the given [plugin] or [taskId] + * + * If no channel can be found, returns null + * + * Will attempt to match on [plugin] first + */ + fun backgroundChannel( + plugin: BDPlugin? = null, + taskId: String = "bgd_non_existent_id" + ): MethodChannel? { + return plugin?.backgroundChannel ?: bgChannelByTaskId[taskId] ?: firstBackgroundChannel + } + } + + private var channel: MethodChannel? = null + private var backgroundChannel: MethodChannel? = null + private lateinit var applicationContext: Context + private lateinit var mainScope: CoroutineScope + private lateinit var defaultScope: CoroutineScope + private var binaryMessenger: BinaryMessenger? = null + var activity: Activity? = null + + + /** + * Attaches the plugin to the Flutter engine and performs initialization + */ + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + applicationContext = flutterPluginBinding.applicationContext + mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + binaryMessenger = flutterPluginBinding.binaryMessenger + backgroundChannel = + MethodChannel( + flutterPluginBinding.binaryMessenger, + "com.bbflight.background_downloader.background" + ) + if (firstBackgroundChannel == null) { + firstBackgroundChannel = backgroundChannel + } + channel = MethodChannel( + flutterPluginBinding.binaryMessenger, "com.bbflight.background_downloader" + ) + channel?.setMethodCallHandler(this) + val uriUtilsChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, + "com.bbflight.background_downloader.uriutils" + ) + uriUtilsChannel.setMethodCallHandler(UriUtilsMethodCallHelper(this)) + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + requireWifi = RequireWiFi.entries[prefs.getInt(keyRequireWiFi, 0)] + // Clear expired items on a background dispatcher to avoid blocking the main thread + defaultScope.launch { + val workManager = WorkManager.getInstance(applicationContext) + val allWorkInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfosByTag(TAG).get() + } + if (allWorkInfos.isEmpty()) { + prefsLock.write { + // remove persistent storage if no jobs found at all + prefs.edit { + remove(keyTasksMap) + } + } + } + } + } + + + /** + * Free up resources. + * + * BinaryMessenger and Plugin references are set to null and removed. + * BackgroundChannel is set to null, and references to it removed if it no longer in use anywhere + * */ + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + mainScope.cancel() + defaultScope.cancel() + channel?.setMethodCallHandler(null) + channel = null + bgChannelByTaskId = + bgChannelByTaskId.filter { it.value != backgroundChannel } as MutableMap + if (firstBackgroundChannel == backgroundChannel) { + firstBackgroundChannel = null + } + backgroundChannel = null + binaryMessenger = null + } + + /** Processes the methodCall coming from Dart */ + override fun onMethodCall(call: MethodCall, result: Result) { + mainScope.launch { + try { + val res: Any? = when (call.method) { + "enqueue" -> methodEnqueue(call) + "enqueueAll" -> methodEnqueueAll(call) + "reset" -> methodReset(call) + "allTasks" -> methodAllTasks(call) + "cancelTasksWithIds" -> methodCancelTasksWithIds(call) + "killTaskWithId" -> methodKillTaskWithId(call) + "taskForId" -> methodTaskForId(call) + "pause" -> methodPause(call) + "pauseAll" -> methodPauseAll(call) + "updateNotification" -> methodUpdateNotification(call) + "moveToSharedStorage" -> methodMoveToSharedStorage(call) + "pathInSharedStorage" -> methodPathInSharedStorage(call) + "openFile" -> methodOpenFile(call) + "requireWiFi" -> methodRequireWiFi(call) + "getRequireWiFiSetting" -> methodGetRequireWiFiSetting() + // ParallelDownloadTask child updates + "chunkStatusUpdate" -> methodUpdateChunkStatus(call) + "chunkProgressUpdate" -> methodUpdateChunkProgress(call) + // Permissions + "permissionStatus" -> methodPermissionStatus(call) + "requestPermission" -> methodRequestPermission(call) + "shouldShowPermissionRationale" -> methodShouldShowPermissionRationale(call) + // internal use + "popResumeData" -> methodPopResumeData() + "popStatusUpdates" -> methodPopStatusUpdates() + "popProgressUpdates" -> methodPopProgressUpdates() + "getTaskTimeout" -> methodGetTaskTimeout() + "registerCallbackDispatcher" -> methodRegisterCallbackDispatcher(call) + // configuration + "configForegroundFileSize" -> methodConfigForegroundFileSize(call) + "configProxyAddress" -> methodConfigProxyAddress(call) + "configProxyPort" -> methodConfigProxyPort(call) + "configRequestTimeout" -> methodConfigRequestTimeout(call) + "configBypassTLSCertificateValidation" -> methodConfigBypassTLSCertificateValidation() + "configCheckAvailableSpace" -> methodConfigCheckAvailableSpace(call) + "configUseCacheDir" -> methodConfigUseCacheDir(call) + "configUseExternalStorage" -> methodConfigUseExternalStorage(call) + "configHoldingQueue" -> methodConfigHoldingQueue(call) + "configSkipExistingFiles" -> methodConfigSkipExistingFiles(call) + "configTempFilePath" -> methodConfigTempFilePath(call) + "platformVersion" -> methodPlatformVersion() + "forceFailPostOnBackgroundChannel" -> methodForceFailPostOnBackgroundChannel( + call + ) + + "testSuggestedFilename" -> methodTestSuggestedFilename(call) + + else -> throw MethodCallException( + "notImplemented", + "Method ${call.method} not implemented", + null + ) + } + result.success(res) + } catch (e: MethodCallException) { + result.error(e.errorCode, e.errorMessage, e.errorDetails) + } + } + } + + + /** + * Starts one task, passed as map of values representing a [Task] + * + * Returns true if successful, and will emit a status update that the task is running. + */ + private suspend fun methodEnqueue(call: MethodCall): Boolean = + withContext(defaultScope.coroutineContext) { + // Arguments are a list of Task, NotificationConfig?, optionally followed + // by tempFilePath, startByte and eTag if this enqueue is a resume from pause + val args = call.arguments as List<*> + val taskJsonMapString = args[0] as String + val task = bdJson.decodeFromString(taskJsonMapString) + pausedTaskIds.remove(task.taskId) // reset flag from an earlier pause + val notificationConfigJsonString = args[1] as String? + val isResume = args.size == 5 + val resumeData: ResumeData? = if (isResume) { + val startByte = if (args[3] is Long) args[3] as Long else (args[3] as Int).toLong() + val eTag = args[4] as String? + ResumeData(task, args[2] as String, startByte, eTag) + } else { + null + } + // validate the task.url + try { + URL(task.url) + URLDecoder.decode(task.url, "UTF-8") + } catch (_: MalformedURLException) { + Log.i(TAG, "MalformedURLException for taskId ${task.taskId}") + return@withContext false + } catch (_: IllegalArgumentException) { + Log.i(TAG, "Could not url-decode url for taskId ${task.taskId}") + return@withContext false + } + // enqueue or add to HoldingQueue + if (holdingQueue == null) { + doEnqueue( + applicationContext, + task, + notificationConfigJsonString, + resumeData, + plugin = this@BDPlugin + ) + } else { + Log.i(TAG, "Enqueueing task with id ${task.taskId} to the HoldingQueue") + holdingQueue?.add( + EnqueueItem( + context = applicationContext, + task = task, + notificationConfigJsonString = notificationConfigJsonString, + resumeData = resumeData, + plugin = this@BDPlugin + ) + ) + processStatusUpdate( + task, + TaskStatus.enqueued, + PreferenceManager.getDefaultSharedPreferences(applicationContext), + context = applicationContext + ) + true + } + } + + /** + * Starts a list of tasks, passed as a JSON string representing a list of [Task] objects. + * + * Returns a list of booleans indicating the success of each individual enqueue operation. + */ + private suspend fun methodEnqueueAll(call: MethodCall): List = + withContext(defaultScope.coroutineContext) { + val plugin = this@BDPlugin + val args = call.arguments as List<*> + val taskListJsonString = args[0] as String + val notificationConfigListJsonString = args[1] as String + try { + val tasks = bdJson.decodeFromString>(taskListJsonString) + val notificationConfigs = + bdJson.decodeFromString>( + notificationConfigListJsonString + ) + val results = mutableListOf() + for ((index, task) in tasks.withIndex()) { + pausedTaskIds.remove(task.taskId) // reset flag from an earlier pause + val notificationConfig = notificationConfigs.getOrNull(index) + val notificationConfigJsonString = + notificationConfig?.let { bdJson.encodeToString(it) } + var success = false + try { + try { + URL(task.url) + URLDecoder.decode(task.url, "UTF-8") + } catch (_: MalformedURLException) { + Log.i(TAG, "MalformedURLException for taskId ${task.taskId}") + results.add(false) + continue + } catch (_: IllegalArgumentException) { + Log.i(TAG, "Could not url-decode url for taskId ${task.taskId}") + results.add(false) + continue + } + // Enqueue or add to HoldingQueue + success = if (holdingQueue == null) { + doEnqueue( + applicationContext, + task, + notificationConfigJsonString, + resumeData = null, + plugin = plugin + ) + } else { + Log.i(TAG, "Enqueueing task with id ${task.taskId} to the HoldingQueue") + holdingQueue?.add( + EnqueueItem( + context = applicationContext, + task = task, + notificationConfigJsonString = notificationConfigJsonString, + resumeData = null, + plugin = plugin + ) + ) + processStatusUpdate( + task, + TaskStatus.enqueued, + PreferenceManager.getDefaultSharedPreferences(applicationContext), + context = applicationContext + ) + true + } + } catch (e: Exception) { + Log.e(TAG, "Error processing task ${task.taskId}: ${e.message}") + } + results.add(success) + } + results + } catch (e: Exception) { + Log.e(TAG, "Error decoding JSON: ${e.message}") + emptyList() // Return an empty list on error + } + } + + /** + * Resets the download worker by cancelling all ongoing download tasks for the group + * + * Returns the number of tasks canceled + */ + private suspend fun methodReset(call: MethodCall): Int = withContext(defaultScope.coroutineContext) { + val group = call.arguments as String + holdingQueue?.stateMutex?.lock() + var counter = holdingQueue?.cancelAllTasks(applicationContext, group) ?: 0 + val tasksMap: MutableMap + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + prefsLock.read { + tasksMap = getTaskMap(prefs) + } + val workManager = WorkManager.getInstance(applicationContext) + val workInfos = workManager.getWorkInfosByTag(TAG).get() + .filter { !it.state.isFinished && it.tags.contains("group=$group") } + for (workInfo in workInfos) { + val tags = workInfo.tags.filter { it.contains("taskId=") } + if (tags.isNotEmpty()) { + val taskId = tags.first().substring(7) + val task = tasksMap[taskId] + if (task != null) { + canceledTaskIds.add(task.taskId) + val releaseSlot = releaseSlotAtCancel(workInfo.id.toString()) + processStatusUpdate( + task, + TaskStatus.canceled, + prefs, + context = applicationContext + ) + if (releaseSlot) { + holdingQueue?.taskFinished(task) + } + } + } + workManager.cancelWorkById(workInfo.id) + counter++ + } + if (Build.VERSION.SDK_INT >= 34) { + val jobScheduler = + applicationContext.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val jobs = jobScheduler.allPendingJobs + for (jobInfo in jobs) { + val taskJson = jobInfo.extras.getString(TaskWorker.keyTask) + if (taskJson != null) { + try { + val task = bdJson.decodeFromString(taskJson) + if (task.group == group) { + canceledTaskIds.add(task.taskId) + processStatusUpdate( + task, + TaskStatus.canceled, + prefs, + context = applicationContext + ) + holdingQueue?.taskFinished(task) + // remove outstanding notification for task or group + val notificationGroup = + NotificationService.groupNotificationWithTaskId(task.taskId) + with(NotificationManagerCompat.from(applicationContext)) { + if (notificationGroup == null) { + cancel(task.taskId.hashCode()) + } else { + // update notification for group + NotificationService.createUpdateNotificationWorker( + applicationContext, + bdJson.encodeToString(task), + bdJson.encodeToString(notificationGroup.notificationConfig), + TaskStatus.canceled.ordinal + ) + } + } + jobScheduler.cancel(jobInfo.id) + counter++ + } + } catch (e: Exception) { + Log.e(TAG, "Error decoding task from JobScheduler: $e") + } + } + } + } + holdingQueue?.stateMutex?.unlock() + Log.v(TAG, "methodReset removed $counter unfinished tasks in group $group") + counter + } + + /** + * Returns a list of tasks for all tasks in progress, as a list of JSON strings, + * optionally filtered by group + */ + private suspend fun methodAllTasks(call: MethodCall): List = + withContext(defaultScope.coroutineContext) { + val group = call.arguments as String? + val tasksAsListOfJsonStrings = mutableListOf() + holdingQueue?.stateMutex?.lock() + holdingQueue?.allTasks(group) + ?.forEach { tasksAsListOfJsonStrings.add(bdJson.encodeToString(it)) } + val workManager = WorkManager.getInstance(applicationContext) + val workInfos = workManager.getWorkInfosByTag(TAG).get() + .filter { !it.state.isFinished && (group == null || it.tags.contains("group=$group")) } + val tasksMap: MutableMap + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + prefsLock.read { + tasksMap = getTaskMap(prefs) + } + for (workInfo in workInfos) { + val tags = workInfo.tags.filter { it.contains("taskId=") } + if (tags.isNotEmpty()) { + val taskId = tags.first().substring(7) + val task = tasksMap[taskId] + if (task != null) { + tasksAsListOfJsonStrings.add(bdJson.encodeToString(task)) + } + } + } + if (Build.VERSION.SDK_INT >= 34) { + val jobScheduler = + applicationContext.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val jobs = jobScheduler.allPendingJobs + for (jobInfo in jobs) { + val taskJson = jobInfo.extras.getString(TaskWorker.keyTask) + if (taskJson != null) { + try { + val task = bdJson.decodeFromString(taskJson) + if (group == null || task.group == group) { + tasksAsListOfJsonStrings.add(taskJson) + } + } catch (e: Exception) { + Log.e(TAG, "Error decoding task from JobScheduler: $e") + } + } + } + } + holdingQueue?.stateMutex?.unlock() + Log.v( + TAG, + "Returning ${tasksAsListOfJsonStrings.size} unfinished tasks in group $group" + ) + tasksAsListOfJsonStrings + } + + /** + * Cancels ongoing tasks whose taskId is in the list provided with this call + * + * Returns true if all cancellations were successful + */ + private suspend fun methodCancelTasksWithIds(call: MethodCall): Boolean = + withContext(defaultScope.coroutineContext) { + @Suppress("UNCHECKED_CAST") val taskIds = call.arguments as List + cancelTasksWithIds(applicationContext, taskIds) + } + + /** + * Kills task with taskId provided as argument in call + * + * Killing differs from canceling in that it only removes the task from the WorkManager + * schedule, without emitting any status updates. It is used to prevent the WorkManager from + * rescheduling WorkManager tasks that are canceled because a constraint is no longer met, e.g. + * network disconnect. We want to handle such errors ourselves, using our retry mechanism, + * and not let the WorkManager reschedule those tasks. The killTask method is therefore called + * whenever a task emits a 'failed' update, as we have no way to determine if the task failed + * with the worker 'SUCCESS' or with the worker 'CANCELED' + */ + private suspend fun methodKillTaskWithId(call: MethodCall): Any? { + val taskId = call.arguments as String + withContext(defaultScope.coroutineContext) { + val workManager = WorkManager.getInstance(applicationContext) + val operation = workManager.cancelAllWorkByTag("taskId=$taskId") + try { + operation.result.get() + } catch (_: Throwable) { + Log.w( + TAG, + "Could not kill task wih id $taskId in operation: $operation" + ) + } + } + return null + } + + /** + * Store the tempFilePath config in shared preferences + */ + private suspend fun methodConfigTempFilePath(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigTempFilePath, call.arguments as String?) + } + return null + } + + /** Returns Task for this taskId, or nil */ + private suspend fun methodTaskForId(call: MethodCall): String? = + withContext(defaultScope.coroutineContext) { + val taskId = call.arguments as String + Log.v(TAG, "Returning task for taskId $taskId") + holdingQueue?.stateMutex?.lock() + var foundTask = holdingQueue?.taskForId(taskId) + if (foundTask == null) { + prefsLock.read { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val tasksMap = getTaskMap(prefs) + foundTask = tasksMap[taskId] + } + } + holdingQueue?.stateMutex?.unlock() + foundTask?.let { bdJson.encodeToString(it) } + } + + /** + * Marks the taskId for pausing + * + * The pause action is taken in the [TaskWorker] + */ + private fun methodPause(call: MethodCall): Boolean { + val taskId = call.arguments as String + return pauseTaskWithId(taskId) + } + + /** + * Marks all taskIds for pausing + */ + private fun methodPauseAll(call: MethodCall): List { + val taskIds = call.arguments as? List<*> ?: throw MethodCallException( + "INVALID_ARGUMENT", + "Expected a list of task IDs", + null + ) + return taskIds.map { taskId -> + if (taskId is String) { + pauseTaskWithId(taskId) + } else { + false + } + } + } + + /** + * Update the notification for this task + * + * Args are: + * - task + * - notificationConfig (cannot be null) + * - taskStatus as ordinal in TaskStatus enum. If null, delete the notification + */ + private suspend fun methodUpdateNotification(call: MethodCall): Any? = + withContext(defaultScope.coroutineContext) { + val args = call.arguments as List<*> + val taskJsonMapString = args[0] as String + val notificationConfigJsonString = args[1] as String + val taskStatusOrdinal = args[2] as Int? + NotificationService.createUpdateNotificationWorker( + applicationContext, + taskJsonMapString, + notificationConfigJsonString, + taskStatusOrdinal + ) + null + } + + + /** + * Returns a JSON String of a map of [ResumeData], keyed by taskId, that has been stored + * in local shared preferences because they could not be delivered to the Dart side. + * Local storage of this map is then cleared + */ + private suspend fun methodPopResumeData(): String { + return withContext(defaultScope.coroutineContext) { + popLocalStorage(keyResumeDataMap) + } + } + + /** + * Returns a JSON String of a map of [Task] and [TaskStatus], keyed by taskId, stored + * in local shared preferences because they could not be delivered to the Dart side. + * Local storage of this map is then cleared + */ + private suspend fun methodPopStatusUpdates(): String { + return withContext(defaultScope.coroutineContext) { + popLocalStorage(keyStatusUpdateMap) + } + } + + /** + * Returns a JSON String of a map of [ResumeData], keyed by taskId, that has been stored + * in local shared preferences because they could not be delivered to the Dart side. + * Local storage of this map is then cleared + */ + private suspend fun methodPopProgressUpdates(): String { + return withContext(defaultScope.coroutineContext) { + popLocalStorage(keyProgressUpdateMap) + } + } + + /** + * Pops and returns locally stored data for this key as a JSON String + * + * The Json string represents a Map, keyed by TaskId, where each item is a Json representation + * of the object stored, e.g. a [TaskStatusUpdate] + */ + private fun popLocalStorage(prefsKey: String): String { + return prefsLock.write { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val jsonString = prefs.getString(prefsKey, "{}") + prefs.edit { + remove(prefsKey) + } + jsonString ?: "{}" + } + } + + /** + * Move a file to Android scoped/shared storage and return the path to that file, or null + * + * Call arguments: + * - filePath (String): full path to file to be moved + * - destination (Int as index into [SharedStorage] enum) + * - directory (String): subdirectory within scoped storage + * - mimeType (String?): mimeType of the file, overrides derived mimeType + * - asUriString (Boolean): if set, returns the path not as a filePath but as a Uri + */ + private suspend fun methodMoveToSharedStorage(call: MethodCall): String? = + withContext(defaultScope.coroutineContext) { + val args = call.arguments as List<*> + val filePath = args[0] as String + val destination = SharedStorage.entries[args[1] as Int] + val directory = args[2] as String + val mimeType = args[3] as String? + val asUriString = args[4] as Boolean + // first check and potentially ask for permissions + val status = PermissionsService.getPermissionStatus( + applicationContext, + PermissionType.androidSharedStorage + ) + if (status == PermissionStatus.granted) { + moveToSharedStorage( + applicationContext, + filePath, + destination, + directory, + mimeType, + asUriString + ) + } else { + Log.i(TAG, "No permission to move to shared storage") + null + } + } + + /** + * Returns path to file in Android scoped/shared storage, or null + * + * If asAndroidUri is true, returns the URI if possible, otherwise falls back to file path + * + * Call arguments: + * - filePath (String): full path to file (only the name is used) + * - destination (Int as index into [SharedStorage] enum) + * - directory (String): subdirectory within scoped storage (ignored for Q+) + * - asUriString (Boolean): if true, returns the URI instead of the path + * + * For Android Q+ uses the MediaStore, matching on filename only, i.e. ignoring + * the directory + */ + private suspend fun methodPathInSharedStorage(call: MethodCall): String? { + val args = call.arguments as List<*> + val filePath = args[0] as String + val destination = SharedStorage.entries[args[1] as Int] + val directory = args[2] as String + val asUriString = args[3] as Boolean + return withContext(defaultScope.coroutineContext) { + pathInSharedStorage( + applicationContext, + filePath, + destination, + directory, + asUriString + ) + } + } + + /** + * Open the file represented by the task, with optional mimeType + * + * Call arguments are [taskJsonMapString, filename, mimeType] with precondition that either + * task or filename is not null + */ + private fun methodOpenFile(call: MethodCall): Boolean { + val args = call.arguments as List<*> + val taskJsonMapString = args[0] as String? + val task = + if (taskJsonMapString != null) bdJson.decodeFromString(taskJsonMapString) else null + val filePath = args[1] as String? ?: run { + val (_, uri) = UriUtils.unpack(task!!.filename) + uri?.toString() ?: task.filePath(applicationContext) + } + val mimeType = args[2] as String? ?: getMimeType(filePath) + return if (activity != null) doOpenFile(activity!!, filePath, mimeType) else false + } + + /** + * Set WiFi requirement globally, based on requirement. + * + * Affects future tasks and reschedules enqueued, inactive tasks + * with the new setting. + * Reschedules active tasks if rescheduleRunning is true, + * otherwise leaves those running with their prior setting + * + * - requirement is first argument (enum) + * - rescheduleRunning is second argument (bool) + * + * Returns true always + */ + private suspend fun methodRequireWiFi(call: MethodCall): Boolean = withContext(defaultScope.coroutineContext) { + val args = call.arguments as List<*> + val newRequireWiFi = RequireWiFi.entries[args[0] as Int] + val rescheduleRunning = args[1] as Boolean + val alsoRestartUploads = args[2] as Boolean + WiFi.requireWiFiChange( + RequireWiFiChange( + applicationContext, + newRequireWiFi, + rescheduleRunning, + alsoRestartUploads + ) + ) + true + } + + /** + * Returns the current global setting for 'requireWiFi', as an ordinal + */ + private suspend fun methodGetRequireWiFiSetting(): Int { + return withContext(defaultScope.coroutineContext) { + PreferenceManager.getDefaultSharedPreferences(applicationContext).getInt( + keyRequireWiFi, 0 + ) + } + } + + /** + * Update the status of one chunk (part of a ParallelDownloadTask), and returns + * the status of the parent task based on the 'sum' of its children, or null + * if unchanged + * + * Arguments are the parent TaskId, chunk taskId, taskStatusOrdinal + */ + private suspend fun methodUpdateChunkStatus(call: MethodCall): Any? = withContext(defaultScope.coroutineContext) { + val args = call.arguments as List<*> + val taskId = args[0] as String + val chunkTaskId = args[1] as String + val statusOrdinal = args[2] as Int + val exceptionJson = args[3] as String? + try { + val exception = if (exceptionJson != null) { + bdJson.decodeFromString(exceptionJson) + } else null + val responseBody = args[4] as String? + parallelDownloadTaskWorkers[taskId]?.chunkStatusUpdate( + chunkTaskId, + TaskStatus.entries[statusOrdinal], + exception, + responseBody + ) + } catch (e: Exception) { + Log.w(TAG, "Exception $e") + Log.w(TAG, "exceptionJson = $exceptionJson") + e.printStackTrace() + } + null + } + + /** + * Update the progress of one chunk (part of a ParallelDownloadTask), and returns + * the progress of the parent task based on the average of its children + * + * Arguments are the parent [TaskId, chunk taskId, progress] + */ + private suspend fun methodUpdateChunkProgress(call: MethodCall): Any? = withContext(defaultScope.coroutineContext) { + val args = call.arguments as List<*> + val taskId = args[0] as String + val chunkTaskId = args[1] as String + val progress = args[2] as Double + parallelDownloadTaskWorkers[taskId]?.chunkProgressUpdate( + chunkTaskId, + progress + ) + null + } + + /** + * Return [PermissionStatus] for this [PermissionType] + */ + private fun methodPermissionStatus(call: MethodCall): Int { + val permissionType = PermissionType.entries[call.arguments as Int] + return PermissionsService.getPermissionStatus( + applicationContext, + permissionType + ).ordinal + } + + /** + * Request permission for this [PermissionType] + * + * Returns true if request was submitted successfully, and the + * Flutter side should wait for completion via the background + * channel method "permissionRequestResult" + */ + private fun methodRequestPermission(call: MethodCall): Boolean { + val permissionType = PermissionType.entries[call.arguments as Int] + return PermissionsService.requestPermission(this, permissionType) + } + + /** + * Returns true if you should show a rationale for this [PermissionType] + */ + private fun methodShouldShowPermissionRationale(call: MethodCall): Boolean { + val permissionType = PermissionType.entries[call.arguments as Int] + return PermissionsService.shouldShowRequestPermissionRationale( + this, + permissionType + ) + } + + /** + * Returns the [TaskWorker] timeout value in milliseconds + * + * For testing only + */ + private fun methodGetTaskTimeout(): Long { + return TaskRunner.taskTimeoutMillis + } + + /** + * Store rawHandle for callbackDispatcher in shared preferences + * + * Dispatcher is called just before passing callback via methodChannel, to ensure + * that Dart is listening to the methodChannel before calling the callback. + */ + private suspend fun methodRegisterCallbackDispatcher(call: MethodCall): Any? { + val handle = call.arguments as Long? + withContext(defaultScope.coroutineContext) { + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit().apply { + if (handle != null) { + Log.d(TAG, "Registering callbackDispatcher handle $handle") + putLong(keyCallbackDispatcherRawHandle, handle) + } else { + remove(keyCallbackDispatcherRawHandle) + } + apply() + } + } + // the Dart side keeps the handle, and stops registering it, only when this is true + return handle != null + } + + + /** + * Store foregroundFileSize in shared preferences + * + * The value is in MB, or -1 to disable foreground always, and + * is retrieved in [TaskWorker.doWork] + */ + private suspend fun methodConfigForegroundFileSize(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + val fileSize = call.arguments as Int + updateSharedPreferences(keyConfigForegroundFileSize, fileSize) + val msg = when (fileSize) { + 0 -> "Enabled foreground mode for all tasks" + -1 -> "Disabled foreground mode for all tasks" + else -> "Set foreground file size threshold to $fileSize MB" + } + Log.v(TAG, msg) + } + return null + } + + /** + * Store the proxy address config in shared preferences + */ + private suspend fun methodConfigProxyAddress(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit().apply { + val address = call.arguments as String? + if (address != null) { + putString(keyConfigProxyAddress, address) + } else { + remove(keyConfigProxyAddress) + } + apply() + } + } + return null + } + + /** + * Store the proxy port config in shared preferences + */ + private suspend fun methodConfigProxyPort(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigProxyPort, call.arguments as Int?) + } + return null + } + + /** + * Store the requestTimeout config in shared preferences + */ + private suspend fun methodConfigRequestTimeout(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigRequestTimeout, call.arguments as Int?) + } + return null + } + + /** + * Bypass the certificate validation + */ + private suspend fun methodConfigBypassTLSCertificateValidation(): Any? { + withContext(defaultScope.coroutineContext) { + acceptUntrustedCertificates() + } + return null + } + + /** + * Store the availableSpace config in shared preferences + */ + private suspend fun methodConfigCheckAvailableSpace(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigCheckAvailableSpace, call.arguments as Int?) + } + return null + } + + /** + * Store the useCacheDir config in shared preferences + */ + private suspend fun methodConfigUseCacheDir(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigUseCacheDir, call.arguments as Int?) + } + return null + } + + /** + * Store the useExternalStorage config in shared preferences + */ + private suspend fun methodConfigUseExternalStorage(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigUseExternalStorage, call.arguments as Int?) + } + return null + } + + + /** + * Configure the holding queue + */ + private fun methodConfigHoldingQueue(call: MethodCall): Any? { + val arguments = call.arguments as List<*> + if (arguments.isEmpty()) { // deactivate the holding queue + holdingQueue = null + } else { + holdingQueue = holdingQueue ?: HoldingQueue(applicationContext, WorkManager.getInstance(applicationContext)) + holdingQueue?.maxConcurrent = arguments[0] as Int + holdingQueue?.maxConcurrentByHost = arguments[1] as Int + holdingQueue?.maxConcurrentByGroup = arguments[2] as Int + } + return null + } + + /** + * Return the Android API version integer as a String + */ + private fun methodPlatformVersion(): String { + return "${Build.VERSION.SDK_INT}" + } + + + /** + * Sets or resets flag to force failing posting on background channel + * + * For testing only + * Arguments are + * - task as Json String + * - content disposition, or empty for none + * + * Returns suggested filename for this task, based on the task & content disposition + */ + private fun methodForceFailPostOnBackgroundChannel(call: MethodCall): Any? { + forceFailPostOnBackgroundChannel = call.arguments as Boolean + return null + } + + /** + * Tests the content-disposition and url translation + * + * For testing only + * + */ + private fun methodTestSuggestedFilename(call: MethodCall): String { + val args = call.arguments as List<*> + val taskJsonMapString = args[0] as String + val contentDisposition = args[1] as String + val task = bdJson.decodeFromString(taskJsonMapString) + val h = if (contentDisposition.isNotEmpty()) mutableMapOf( + "Content-Disposition" to mutableListOf(contentDisposition) + ) else mutableMapOf("" to mutableListOf()) + val t = task.withSuggestedFilenameFromResponseHeaders(applicationContext, h) + return t.filename + } + + /** + * Helper function to update or delete the [value] in shared preferences under [key] + * + * If [value] is null, the [key] is deleted + */ + private fun updateSharedPreferences(key: String, value: Int?) { + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit().apply { + if (value != null) { + putInt(key, value) + } else { + remove(key) + } + apply() + } + Log.d(TAG, "Setting preference key $key to $value") + } + + + /** + * Helper function to update or delete the [value] String in shared preferences under [key] + * + * If [value] is null, the [key] is deleted + */ + private fun updateSharedPreferences(key: String, value: String?) { + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit().apply { + if (value != null) { + putString(key, value) + } else { + remove(key) + } + apply() + } + Log.d(TAG, "Setting preference key $key to $value") + } + + + /** + * Store the skipExistingFiles config in shared preferences + */ + private suspend fun methodConfigSkipExistingFiles(call: MethodCall): Any? { + withContext(defaultScope.coroutineContext) { + updateSharedPreferences(keyConfigSkipExistingFiles, call.arguments as Int?) + } + return null + } + +// ActivityAware implementation to capture Activity context needed for permissions and intents + + /** + * Handle intent if received from tapping a notification + * + * This may be called on startup of the application and at that time the [backgroundChannel] and + * its listener may not have been initialized yet. This function therefore includes retry logic. + */ + private fun handleIntent(intent: Intent?): Boolean { + if (intent != null && intent.action == NotificationReceiver.actionTap) { + // if taskJsonMapString == null, this was a main launch and we ignore + val taskJsonMapString = + intent.getStringExtra(NotificationReceiver.keyTask) ?: return true + val notificationTypeOrdinal = + intent.getIntExtra(NotificationReceiver.keyNotificationType, 0) + val notificationId = intent.getIntExtra(NotificationReceiver.keyNotificationId, 0) + // only process notificationTap and tapOpensFile if we have task data + if (taskJsonMapString.isNotEmpty()) { + CoroutineScope(Dispatchers.Default).launch { + var retries = 0 + var success = false + while (retries < 5 && !success) { + try { + if (backgroundChannel != null) { + val resultCompleter = CompletableDeferred() + withContext(Dispatchers.Main) { + backgroundChannel?.invokeMethod( + "notificationTap", + listOf(taskJsonMapString, notificationTypeOrdinal), + FlutterBooleanResultHandler(resultCompleter) + ) + } + success = resultCompleter.await() + } + } catch (e: Exception) { + Log.v(TAG, "Exception in handleIntent: $e") + } + if (!success) { + delay(timeMillis = 100L shl (retries)) + retries++ + } + } + } + // check for 'tapOpensFile' + if (notificationTypeOrdinal == NotificationType.complete.ordinal) { + val task = bdJson.decodeFromString(taskJsonMapString) + val notificationConfigJsonString = + intent.extras?.getString(NotificationReceiver.keyNotificationConfig) + val notificationConfig = + if (notificationConfigJsonString != null) bdJson.decodeFromString( + notificationConfigJsonString + ) + else null + if (notificationConfig?.tapOpensFile == true && activity != null) { + val (_, uri) = UriUtils.unpack(task.filename) + val filePathOrUri = uri?.toString() ?: task.filePath(activity!!) + doOpenFile(activity!!, filePathOrUri, getMimeType(filePathOrUri)) + } + } + } + // dismiss notification if it is a 'complete' or 'error' notification + if (notificationId != 0 && + (notificationTypeOrdinal == NotificationType.complete.ordinal || notificationTypeOrdinal == NotificationType.error.ordinal) + ) { + with(NotificationManagerCompat.from(applicationContext)) { + cancel(notificationId) + } + } + return true + } + return false + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + attach(binding) + handleIntent(binding.activity.intent) + } + + override fun onDetachedFromActivityForConfigChanges() { + detach() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + attach(binding) + } + + override fun onDetachedFromActivity() { + detach() + } + + + /** + * Attach to activity + */ + private fun attach(binding: ActivityPluginBinding) { + detach() + activity = binding.activity + binding.addRequestPermissionsResultListener(this) + binding.addActivityResultListener(this) + binding.addOnNewIntentListener(fun(intent: Intent?): Boolean { + return handleIntent(intent) + }) + if (notificationButtonText.isEmpty()) { + // store localized strings so they can be used in notifications even when the + // activity is not alive + notificationButtonText["Cancel"] = + activity!!.getString(R.string.bg_downloader_cancel) + notificationButtonText["Pause"] = activity!!.getString(R.string.bg_downloader_pause) + notificationButtonText["Resume"] = + activity!!.getString(R.string.bg_downloader_resume) + } + } + + /** + * Detach from activity + * + * Because we don't know which activity, we can't really do much here + */ + private fun detach() { + activity = null + } + + /** + * Receives onRequestPermissionsResult and passes this to the + * [PermissionsService] + */ + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ): Boolean { + return PermissionsService.onRequestPermissionsResult( + this, + requestCode, + grantResults + ) + } + + /** + * Handle other onActivityResult calls not related to permissions + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (DirectoryPicker.handleActivityResult( + applicationContext, + requestCode, + resultCode, + data + ) + ) { + return true + } else if (FilePicker.handleActivityResult( + applicationContext, + requestCode, + resultCode, + data + ) + ) { + return true + } + return false + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Callbacks.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Callbacks.kt new file mode 100644 index 0000000..a7e39c9 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Callbacks.kt @@ -0,0 +1,199 @@ +package com.bbflight.background_downloader + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.preference.PreferenceManager +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.FlutterCallbackInformation +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +class Callbacks { + companion object { + const val TAG = "Callbacks" + private var callbackMethodChannel: MethodChannel? = null + private val methodChannelMutex = Mutex() + + /** + * Retrieves the [MethodChannel] used for communication with the Flutter background isolate. + * + * This method initializes the `MethodChannel` if it hasn't been created yet. It does so by: + * 1. Obtaining the `callbackDispatcherRawHandle` from shared preferences. + * 2. Creating a new [FlutterEngine] and executing the Dart callback dispatcher. + * 3. Creating a [MethodChannel] using the engine's binary messenger. + * + * The initialization process happens on the UI thread to avoid potential issues. + * + * This method is thread-safe due to the use of a mutex. + * + * @param context The [Context] required for accessing resources and the UI thread. + * @return The [MethodChannel] instance, or `null` if initialization fails. + */ + private suspend fun getMethodChannel(context: Context): MethodChannel? { + methodChannelMutex.withLock { + if (callbackMethodChannel == null) { + // create new flutterEngine and invoke callbackDispatcher, on the UI thread + val methodChannelCompleter = CompletableDeferred() + Handler(Looper.getMainLooper()).post { // Run on UI thread + try { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val rawHandle = + prefs.getLong(BDPlugin.keyCallbackDispatcherRawHandle, -1L) + if (rawHandle == -1L) { + Log.w( + TAG, + "getMethodChannel without registered callbackDispatcherRawHandle" + ) + methodChannelCompleter.complete(null) + return@post + } + val flutterEngine = FlutterEngine(context, null, false) + val callbackDispatcherCallback = + FlutterCallbackInformation.lookupCallbackInformation(rawHandle) + if (callbackDispatcherCallback == null) { + Log.w( + TAG, + "invokeOnStartCallback failed to find callbackDispatcher" + ) + methodChannelCompleter.complete(null) + return@post + } + val appBundlePath: String = + FlutterInjector.instance().flutterLoader().findAppBundlePath() + val assets = context.assets + flutterEngine.dartExecutor.executeDartCallback( + DartExecutor.DartCallback( + assets, + appBundlePath, + callbackDispatcherCallback + ) + ) + callbackMethodChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.bbflight.background_downloader.callbacks" + ) + methodChannelCompleter.complete(callbackMethodChannel) + } catch (e: Exception) { + Log.e(TAG, "Error creating MethodChannel", e) + methodChannelCompleter.complete(null) + } + } + return methodChannelCompleter.await() // Wait for the UI thread to finish + } + return callbackMethodChannel + } + } + + /** + * Invokes a callback method on the Flutter side using the specified method channel. + * + * This function handles the asynchronous communication with Flutter, ensuring thread safety + * and proper handling of the result. It serializes the given `task` object to JSON + * and passes it as an argument to the Flutter method. + * + * @param context The Android context. + * @param task The [Task] object to be passed to the Flutter callback, or null. + * @param statusUpdate The [TaskStatusUpdate] object to be passed to the Flutter callback, or null. + * @param methodName The name of the method to invoke on the Flutter side. + * @return The updated object returned by the Flutter callback, or `null` if + * the task is unchanged, an error occurs, or the method channel is not available. + */ + private suspend fun invokeCallback( + context: Context, + methodName: String, + task: Task? = null, + statusUpdate: TaskStatusUpdate? = null, + ): Any? { + val methodChannel = getMethodChannel(context) ?: return null + methodChannelMutex.withLock { + val resultingObjectAsJsonStringCompleter = CompletableDeferred() + Handler(Looper.getMainLooper()).post { + // Run on UI thread + val arg = if (task != null) bdJson.encodeToString(task) else bdJson.encodeToString( + statusUpdate + ) + Log.i( + TAG, + "Invoking $methodName for taskId ${task?.taskId}" + ) + methodChannel.invokeMethod( + methodName, + arg, // either task or update + FlutterResultHandler(resultingObjectAsJsonStringCompleter) + ) + } + val objectAsJsonString = resultingObjectAsJsonStringCompleter.await() + return if (objectAsJsonString == null) null else + if (methodName == "beforeTaskStartCallback") bdJson.decodeFromString( + objectAsJsonString + ) else bdJson.decodeFromString( + objectAsJsonString + ) + } + } + + /** + * Invoke the beforeTaskStartCallback and return the result + */ + suspend fun invokeBeforeTaskStartCallback(context: Context, task: Task): TaskStatusUpdate? { + try { + return invokeCallback( + context, + "beforeTaskStartCallback", + task = task + ) as TaskStatusUpdate? + } catch (e: Exception) { + Log.e(TAG, "Error in invokeBeforeTaskStartCallback", e) + } + return null + } + + /** + * Invoke the onTaskStartCallback and return the result + */ + suspend fun invokeOnTaskStartCallback(context: Context, task: Task): Task? { + try { + return invokeCallback(context, "onTaskStartCallback", task = task) as Task? + } catch (e: Exception) { + Log.e(TAG, "Error in invokeOnTaskStartCallback", e) + } + return null + } + + /** + * Invoke the invokeOnAuthCallback and return the result + */ + suspend fun invokeOnAuthCallback(context: Context, task: Task): Task? { + try { + return invokeCallback(context, "onAuthCallback", task = task) as Task? + } catch (e: Exception) { + Log.e(TAG, "Error in invokeOnAuthCallback", e) + } + return null + } + + /** + * Invoke the onTaskFinishedCallback + */ + suspend fun invokeOnTaskFinishedCallback(context: Context, statusUpdate: TaskStatusUpdate) { + try { + invokeCallback( + context, + "onTaskFinishedCallback", + statusUpdate = statusUpdate + ) as Task? + } catch (e: Exception) { + Log.e(TAG, "Error in invokeOnTaskFinishedCallback", e) + } + } + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DataTaskRunner.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DataTaskRunner.kt new file mode 100644 index 0000000..ddaa525 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DataTaskRunner.kt @@ -0,0 +1,50 @@ +package com.bbflight.background_downloader + +import android.util.Log +import java.net.HttpURLConnection + +class DataTaskRunner(context: TaskJobContext) : TaskRunner(context) { + + /** Process the response to the GET or POST request on this [connection] + * + * Returns the [TaskStatus] + */ + override suspend fun process( + connection: HttpURLConnection, + ): TaskStatus { + responseStatusCode = connection.responseCode + if (responseStatusCode!! in 200..206) { + extractResponseHeaders(connection.headerFields) + extractContentType(connection.headerFields) + // transfer the bytes from the server to the temp file + return try { + responseBody = connection.inputStream.bufferedReader().readText() + TaskStatus.complete + } catch (e: Exception) { + Log.i( + TAG, + "Could not read response content: $e" + ) + taskException = TaskException(ExceptionType.connection, description = "Could not read response content: $e") + TaskStatus.failed + } + } else { + // HTTP response code not OK + Log.i( + TAG, + "Response code ${connection.responseCode} for taskId ${task.taskId}" + ) + val errorContent = responseErrorContent(connection) + taskException = TaskException( + ExceptionType.httpResponse, httpResponseCode = connection.responseCode, + description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage + ) + return if (responseStatusCode == 404) { + responseBody = errorContent + TaskStatus.notFound + } else { + TaskStatus.failed + } + } + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DataTaskWorker.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DataTaskWorker.kt new file mode 100644 index 0000000..349d52b --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DataTaskWorker.kt @@ -0,0 +1,12 @@ +package com.bbflight.background_downloader + +import android.content.Context +import androidx.work.WorkerParameters + +class DataTaskWorker(context: Context, params: WorkerParameters) : + TaskWorker(context, params) { + + override fun createRunner(): TaskRunner { + return DataTaskRunner(this) + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadNetworkPolicy.java b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadNetworkPolicy.java new file mode 100644 index 0000000..014c8d8 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadNetworkPolicy.java @@ -0,0 +1,22 @@ +package com.bbflight.background_downloader; + +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; + +/** Shared by WorkManager and user-initiated jobs on Android 9 and later. */ +public final class DownloadNetworkPolicy { + private DownloadNetworkPolicy() {} + + public static NetworkRequest request(boolean wifiOnly) { + // Media may be reachable even when an OS connectivity-check host is + // blocked. Require internet capability, not OS network validation. + NetworkRequest.Builder builder = new NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); + if (wifiOnly) { + builder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI); + } + return builder.build(); + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadTaskRunner.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadTaskRunner.kt new file mode 100644 index 0000000..cb11d77 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadTaskRunner.kt @@ -0,0 +1,662 @@ +package com.bbflight.background_downloader + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.storage.StorageManager +import android.util.Log +import androidx.core.net.toFile +import androidx.documentfile.provider.DocumentFile +import androidx.preference.PreferenceManager +import com.bbflight.background_downloader.UriUtils.unpack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.net.HttpURLConnection +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import android.system.Os +import android.system.ErrnoException +import kotlin.math.absoluteValue +import kotlin.random.Random + +class DownloadTaskRunner(context: TaskJobContext) : TaskRunner(context) { + + private var eTagHeader: String? = null + private var serverAcceptsRanges = false // if true, send resume data on fail + private var tempFilePath = "" + private var requiredStartByte = 0L // required start byte within the task range + private var taskRangeStartByte = 0L // Start of the Task's download range + private var eTag: String? = null + + /** + * Make the request to the [connection] and process the [Task] + * + * Returns the [TaskStatus] + * */ + override suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus { + if (isResume) { + val taskRangeHeader = task.headers["Range"] ?: "" + val taskRange = parseRange(taskRangeHeader) + taskRangeStartByte = taskRange.first + val resumeRange = Pair(taskRangeStartByte + requiredStartByte, taskRange.second) + val newRangeString = "bytes=${resumeRange.first}-${resumeRange.second ?: ""}" + connection.setRequestProperty("Range", newRangeString) + } + val result = super.connectAndProcess(connection) + if (result == TaskStatus.canceled) { + deleteTempFile() + } + if (result == TaskStatus.failed) { + prepResumeAfterFailure() + } + return result + } + + /** Process the response to the GET or POST request on this [connection] + * + * Returns the [TaskStatus] + */ + override suspend fun process( + connection: HttpURLConnection, + ): TaskStatus { + // Check if the file should be skipped + // (not possible if the filename will be suggested by the server) + val skipThreshold = prefs.getInt(BDPlugin.keyConfigSkipExistingFiles, -1) + if (skipThreshold != -1 && task.filename != "?") { + val filePath = task.filePath(context.appContext) + val file = File(filePath) + if (file.exists()) { + val fileSize = file.length() + // threshold 0 skips any existing file, including an empty one + if (skipThreshold == 0 || fileSize > skipThreshold * 1024L * 1024L) { + responseStatusCode = 304 + return TaskStatus.complete + } + } + } + + responseStatusCode = connection.responseCode + if (connection.responseCode in 200..206) { + // determine if we are using Uri or not. Uri means pause/resume not allowed + val directoryUri = UriUtils.uriFromStringValue(task.directory) + val usesUri = directoryUri != null + eTagHeader = connection.headerFields["ETag"]?.first() + val acceptRangesHeader = connection.headerFields["Accept-Ranges"] + serverAcceptsRanges = + acceptRangesHeader?.first() == "bytes" || connection.responseCode == 206 + if (task.allowPause && !usesUri) { + taskCanResume = serverAcceptsRanges + processCanResume( + task, + taskCanResume + ) + } + isResume = isResume && connection.responseCode == 206 // confirm resume response + if (isResume && !prepareResume(connection)) { + deleteTempFile() + return TaskStatus.failed + } + if (isResume && (eTagHeader != eTag || eTag?.startsWith("W/") == true)) { + deleteTempFile() + Log.i(TAG, "Cannot resume: ETag is not identical, or is weak") + taskException = TaskException( + ExceptionType.resume, + description = "Cannot resume: ETag is not identical, or is weak" + ) + return TaskStatus.failed + } + // Determine destination - either [destFilePath] or [destUri] + // If no filename is set, get from headers or url, and update the task + var destFilePath = task.filePath(context.appContext) + var (uriFilename, destUri) = unpack(task.filename) + if (!task.hasFilename()) { + // If no filename is set, get from headers or url, and update the task + if (usesUri) { + uriFilename = suggestFilename(connection.headerFields, task.url) + if (uriFilename.isEmpty()) { + uriFilename = "${Random.nextInt().absoluteValue}" + } + task = task.copyWith( + filename = if (destUri == null) uriFilename else UriUtils.pack( + uriFilename, + destUri + ) + ) + } else { + destFilePath = destFilePath(connection) + } + } + extractResponseHeaders(connection.headerFields) + extractContentType(connection.headerFields) + val contentLength = getContentLength(connection.headerFields, task) + // determine tempFile, or set to null if Uri is used + val tempFile = if (!usesUri) { + val applicationSupportPath = + baseDirPath(context.appContext, BaseDirectory.applicationSupport) + val cachePath = baseDirPath(context.appContext, BaseDirectory.temporary) + if (applicationSupportPath == null || cachePath == null) { + throw IllegalStateException("External storage is requested but not available") + } + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context.appContext) + val tempFilePathConfig = sharedPrefs.getString(BDPlugin.keyConfigTempFilePath, null) + val tempDir = if (!tempFilePathConfig.isNullOrEmpty()) { + File(tempFilePathConfig) + } else { + when (sharedPrefs.getInt(BDPlugin.keyConfigUseCacheDir, -2)) { + 0 -> File(cachePath) // 'always' + -1 -> File(applicationSupportPath) // 'never' + else -> { + // 'whenAble' -> determine based on cache quota + val storageManager = + context.appContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager + val cacheQuota = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + storageManager.getCacheQuotaBytes( + storageManager.getUuidForPath( + File(cachePath) + ) + ) + } else { + 50L shl (20) // for older OS versions, assume 50MB + } + if (contentLength < cacheQuota / 2) File(cachePath) else File( + applicationSupportPath + ) + } + } + } + if (!tempDir.exists()) { + tempDir.mkdirs() + } + tempFilePath = + tempFilePath.ifEmpty { "${tempDir.absolutePath}/com.bbflight.background_downloader${Random.nextInt()}" } + + // confirm enough storage space for download + if (insufficientSpace(context.appContext, contentLength)) { + Log.i( + TAG, + "Insufficient space to store the file to be downloaded for taskId ${task.taskId}" + ) + taskException = TaskException( + ExceptionType.fileSystem, + description = "Insufficient space to store the file to be downloaded" + ) + return TaskStatus.failed + } + File(tempFilePath) + } else { + null + } + val outputStream: java.io.OutputStream = if (tempFile != null) { + FileOutputStream(tempFile, isResume) + } else { + // no tempFile, because we have a Uri + uriFilename = uriFilename ?: "unknown" + if (directoryUri!!.scheme == "file") { + // fileUri is converted to a File, then to a FileOutputStream + if (destUri == null) { + // need to create file at directory + val dirObject = directoryUri.toFile() + val destFile = File(dirObject, uriFilename) + destUri = Uri.fromFile(destFile) + // Store destination Uri in task + task = task.copyWith(filename = UriUtils.pack(uriFilename, destUri)) + FileOutputStream(destFile) // return outputStream + } else { + // use destination Uri that was set in previous attempt + FileOutputStream(destUri.toFile()) + } + } else { + // other URL scheme will be attempted to resolve using content resolver + val resolver = context.appContext.contentResolver + // create destination Uri if not already exists + val documentFile = DocumentFile.fromTreeUri(context.appContext, directoryUri) + destUri = destUri ?: documentFile?.createFile(task.mimeType, uriFilename)?.uri + if (destUri == null) { + val message = + "Failed to create document within directory with URI: $directoryUri" + Log.e(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return TaskStatus.failed + } + val newFilename = getFilenameFromUri(destUri) + if (newFilename.isNotEmpty()) { + uriFilename = newFilename + } + task = task.copyWith(filename = UriUtils.pack(uriFilename, destUri)) + val os = resolver.openOutputStream(destUri) + if (os == null) { + val message = "Failed to open output stream for URI: $destUri" + Log.e(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return TaskStatus.failed + } else os + } + } + BDPlugin.remainingBytesToDownload[task.taskId] = contentLength + determineRunInForeground(task, contentLength) // sets 'runInForeground' + context.updateEstimatedNetworkBytes(contentLength, 0L) + // transfer the bytes from the server to the output stream + val transferBytesResult: TaskStatus + outputStream.use { os -> + BufferedInputStream(connection.inputStream).use { inputStream -> + transferBytesResult = transferBytes( + inputStream, os, contentLength, task + ) + } + os.flush() + } + // act on the result of the bytes transfer + when (transferBytesResult) { + TaskStatus.complete -> { + if (tempFile == null) { + Log.i( + TAG, "Successfully downloaded taskId ${task.taskId} to URI $destUri" + ) + } else { + // move file from its temp location to the destination + val destFile = File(destFilePath) + val dir = destFile.parentFile!! + if (!dir.exists()) { + dir.mkdirs() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + withContext(Dispatchers.IO) { + try { + Files.move( + tempFile.toPath(), + destFile.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } catch (e: java.nio.file.FileSystemException) { + // Fallback if moving across different filesystems/mount points (EXDEV / Cross-device link) + Log.d(TAG, "Files.move failed ($e), falling back to copyTo + delete") + tempFile.copyTo(destFile, overwrite = true) + deleteTempFile() + } + } + setFileOwnership(destFile) + } else { + tempFile.copyTo(destFile, overwrite = true) + deleteTempFile() + setFileOwnership(destFile) + } + Log.i( + TAG, "Successfully downloaded taskId ${task.taskId} to $destFilePath" + ) + } + return TaskStatus.complete + } + + TaskStatus.canceled -> { + cleanup(usesUri, destUri) + Log.i(TAG, "Canceled taskId ${task.taskId}") + return TaskStatus.canceled + } + + TaskStatus.paused -> return pauseResult(usesUri, destUri, bytesTotal + startByte) + + TaskStatus.enqueued -> { + // Special status, in this context means that the task timed out + return timeoutResult(usesUri, destUri, bytesTotal + startByte) + } + + TaskStatus.failed -> { + // resume after failure is prepared in [connectAndProcess] + return TaskStatus.failed + } + + else -> { + Log.e(TAG, "Unknown transferBytesResult $transferBytesResult") + cleanup(usesUri, destUri) + return TaskStatus.failed + } + } + } else { + // HTTP response code not OK + Log.i( + TAG, + "Response code ${connection.responseCode} for taskId ${task.taskId}" + ) + val errorContent = responseErrorContent(connection) + taskException = TaskException( + ExceptionType.httpResponse, httpResponseCode = connection.responseCode, + description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage + ) + return if (connection.responseCode == 404) { + responseBody = errorContent + TaskStatus.notFound + } else { + TaskStatus.failed + } + } + } + + /** + * Returns the final [TaskStatus] for a task that was paused, with [resumeStart] + * the number of bytes in the temp file. + * + * Posts resume data if the task can resume, otherwise fails the task + */ + private suspend fun pauseResult(usesUri: Boolean, destUri: Uri?, resumeStart: Long): TaskStatus { + BDPlugin.pausedTaskIds.remove(task.taskId) + if (taskCanResume) { + Log.i(TAG, "Task ${task.taskId} paused") + processResumeData( + ResumeData( + task, tempFilePath, resumeStart, eTagHeader + ), prefs + ) + return TaskStatus.paused + } + if (BDPlugin.tasksToReEnqueue.contains(task) && serverAcceptsRanges) { + // pause was triggered by re-enqueue request due to WiFi requirement change + // so we only store local resumeData without posting it + Log.i(TAG, "Task ${task.taskId} paused in order to re-enqueue") + BDPlugin.localResumeData[task.taskId] = ResumeData( + task, tempFilePath, resumeStart, eTagHeader + ) + return TaskStatus.paused + } + Log.i(TAG, "Task ${task.taskId} cannot resume, therefore pause failed") + taskException = TaskException( + ExceptionType.resume, + description = "Task was paused but cannot resume" + ) + cleanup(usesUri, destUri) + return TaskStatus.failed + } + + /** + * Returns the final [TaskStatus] for a task that timed out, with [resumeStart] + * the number of bytes in the temp file. + * + * If allowed, pauses the task and schedules the resume task immediately, + * otherwise fails the task. Never returns [TaskStatus.enqueued] + */ + private suspend fun timeoutResult(usesUri: Boolean, destUri: Uri?, resumeStart: Long): TaskStatus { + if (!task.allowPause) { + Log.i(TAG, "Task ${task.taskId} timed out") + taskException = + TaskException( + ExceptionType.connection, + description = "Task timed out" + ) + return TaskStatus.failed + } + if (taskCanResume) { + Log.i( + TAG, + "Task ${task.taskId} paused due to timeout, will resume in 1 second" + ) + val success = BDPlugin.doEnqueue( + context.appContext, + task, + notificationConfigJsonString, + ResumeData(task, tempFilePath, resumeStart, eTagHeader), + 1000 + ) + if (!success) { + Log.w(TAG, "Task ${task.taskId} timed out and could not re-enqueue to resume") + taskException = TaskException( + ExceptionType.general, + description = "Task timed out and could not re-enqueue to resume" + ) + cleanup(usesUri, destUri) + return TaskStatus.failed + } + return TaskStatus.paused + } + Log.i(TAG, "Task ${task.taskId} timed out and cannot pause/resume") + taskException = + TaskException(ExceptionType.connection, description = "Task timed out") + cleanup(usesUri, destUri) + return TaskStatus.failed + } + + /** + * Pause interrupted the task outside of [transferBytes], e.g. while waiting + * for the response. Resume from whatever is in the temp file, if possible + */ + override suspend fun handlePause(): TaskStatus { + prepareInterruptedResume() + return pauseResult(usesUriDestination(), unpack(task.filename).second, tempFileLength()) + } + + /** + * Timeout interrupted the task outside of [transferBytes], e.g. while + * waiting for the response. Resume from whatever is in the temp file, if possible + */ + override suspend fun handleTimeout(): TaskStatus { + prepareInterruptedResume() + return timeoutResult(usesUriDestination(), unpack(task.filename).second, tempFileLength()) + } + + /** + * Prepare state for [handlePause] and [handleTimeout]: resume is only + * possible if the temp file holds data, and if the task already + * established that it can resume (in this or in a previous run) + */ + private fun prepareInterruptedResume() { + val hasTempData = !usesUriDestination() && tempFileLength() > 0 + taskCanResume = task.allowPause && hasTempData && (taskCanResume || isResume) + eTagHeader = eTagHeader ?: eTag + } + + /** True if the destination of this task is a Uri */ + private fun usesUriDestination() = UriUtils.uriFromStringValue(task.directory) != null + + /** Length of the temp file, or 0 if it does not exist */ + private fun tempFileLength(): Long { + if (tempFilePath.isEmpty()) return 0 + val tempFile = File(tempFilePath) + return if (tempFile.exists()) tempFile.length() else 0 + } + + /** + * Return destination filePath where the filename is set based on response headers, and + * update the [task] accordingly + */ + private fun destFilePath(connection: HttpURLConnection): String { + val destFilePath = task.filePath(context.appContext) + task = task.withSuggestedFilenameFromResponseHeaders( + context.appContext, + connection.headerFields, + unique = true + ) + val dirName = File(destFilePath).parent ?: "" + return if (dirName.isEmpty()) task.filename else "$dirName/${task.filename}" + } + + + /** + * Return true if this is a resume, and resume is possible, + * given [tempFilePath] and [requiredStartByte] + * */ + override fun determineIfResume(): Boolean { + // set tempFilePath from resume data, or "" if a new tempFile is needed + requiredStartByte = context.getInputLong(keyStartByte, 0) + if (requiredStartByte == 0L) { + return false + } + eTag = context.getInputString(keyETag) + tempFilePath = if (requiredStartByte > 0) context.getInputString(keyResumeDataData) ?: "" + else "" + val tempFile = File(tempFilePath) + if (tempFile.exists()) { + val tempFileLength = tempFile.length() + if (tempFileLength == requiredStartByte) { + return true + } else { + // attempt to truncate the file to the expected size + Log.d( + TAG, + "File length = ${tempFile.length()} vs requiredStartByte = $requiredStartByte" + ) + if (tempFileLength > requiredStartByte && Build.VERSION.SDK_INT >= 26) { + try { + val fileChannel = + FileChannel.open(tempFile.toPath(), StandardOpenOption.WRITE) + fileChannel.truncate(requiredStartByte) + fileChannel.close() + Log.d(TAG, "Truncated temp file to desired length") + return true + } catch (e: IOException) { + e.printStackTrace() + } + } + Log.i(TAG, "Partially downloaded file is corrupted, resume not possible") + } + } else { + Log.i(TAG, "Partially downloaded file not available, resume not possible") + } + return false + } + + /** Prepare for resume if possible + * + * Returns true if task can continue, false if task failed. + * Extracts and parses Range headers, and truncates temp file + */ + private fun prepareResume(connection: HttpURLConnection): Boolean { + if (tempFilePath.isEmpty()) { + return false + } + val contentRanges = connection.headerFields["Content-Range"] + if (contentRanges == null || contentRanges.size > 1) { + Log.i( + TAG, "Could not " + + "process partial response Content-Range" + ) + return false + } + val range = contentRanges.first() + val contentRangeRegEx = Regex("(\\d+)-(\\d+)/(\\d+)") + val matchResult = contentRangeRegEx.find(range) + if (matchResult == null) { + Log.i(TAG, "Could not process partial response Content-Range $range") + taskException = TaskException( + ExceptionType.resume, + description = "Could not process partial response Content-Range $range" + ) + return false + } + val start = matchResult.groups[1]?.value?.toLong()!! + val tempFile = File(tempFilePath) + val tempFileLength = tempFile.length() + startByte = start - taskRangeStartByte // relative to start of range + if (startByte > tempFileLength) { + Log.i(TAG, "Offered range not feasible: $range with startByte $startByte") + taskException = TaskException( + ExceptionType.resume, + description = "Offered range not feasible: $range with startByte $startByte" + ) + return false + } + // resume possible, set start conditions + try { + RandomAccessFile(tempFilePath, "rw").use { it.setLength(startByte) } + } catch (_: IOException) { + Log.i(TAG, "Could not truncate temp file") + taskException = + TaskException( + ExceptionType.resume, + description = "Could not truncate temp file" + ) + return false + } + return true + } + + /** + * Attempt to allow resume after failure by sending resume data + * back to Dart + * + * If this is not possible, the temp file will be deleted + */ + private suspend fun prepResumeAfterFailure() { + // a Uri download has no temp file, and the temp file may have been deleted already + val hasTempFile = tempFilePath.isNotEmpty() && File(tempFilePath).exists() + if (hasTempFile && serverAcceptsRanges && bytesTotal + startByte > 1 shl 20) { + // if failure can be resumed, post resume data + processResumeData( + ResumeData( + task, tempFilePath, bytesTotal + startByte, eTagHeader + ), prefs + ) + } else { + // if it cannot be resumed, delete the temp file + deleteTempFile() + } + } + + /** + * Deletes the temp file associated with this download + */ + private fun deleteTempFile() { + if (tempFilePath.isNotEmpty()) { + try { + val tempFile = File(tempFilePath) + tempFile.delete() + } catch (_: IOException) { + Log.i(TAG, "Could not delete temp file at $tempFilePath") + } + } + } + + /** + * Deletes the destination Uri at [uri] + */ + private fun deleteDestinationUri(uri: Uri) { + try { + context.appContext.contentResolver.delete(uri, null, null) + } catch (_: Exception) { + Log.i(TAG, "Could not delete file at $uri") + } + } + + /** + * Cleanup by deleting the temp file or destination uri + */ + private fun cleanup(usesUri: Boolean, destUri: Uri?) { + if (usesUri && destUri != null) { + deleteDestinationUri(destUri) + } else { + deleteTempFile() + } + } + + /** + * Sets the group ownership of the downloaded file to unmark the file as a cache file. + * + * Determines the app's GID and then calls Os.chown. Logs success or failure. + * Likely to fail if the file is in external storage. + */ + private fun setFileOwnership(destFile: File) { + try { + val finalPath = destFile.absolutePath + val gid = context.appContext.applicationInfo.uid // App's own GID + Os.chown(finalPath, -1, gid) // -1 for UID means keep current owner + } catch (e: ErrnoException) { + // Log the error, common if the filesystem doesn't support chown or due to permissions. + // Check OsConstants for specific errno values if needed. + Log.w( + TAG, + "Failed to change group ownership for ${destFile.absolutePath}: ${e.message}" + ) + } + } + +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadTaskWorker.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadTaskWorker.kt new file mode 100644 index 0000000..6fa80ed --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadTaskWorker.kt @@ -0,0 +1,12 @@ +package com.bbflight.background_downloader + +import android.content.Context +import androidx.work.WorkerParameters + +class DownloadTaskWorker(context: Context, params: WorkerParameters) : + TaskWorker(context, params) { + + override fun createRunner(): TaskRunner { + return DownloadTaskRunner(this) + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Helpers.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Helpers.kt new file mode 100644 index 0000000..321bd3e --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Helpers.kt @@ -0,0 +1,344 @@ +package com.bbflight.background_downloader + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.util.Log +import androidx.core.content.FileProvider.getUriForFile +import androidx.preference.PreferenceManager +import com.bbflight.background_downloader.TaskRunner.Companion.TAG +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CompletableDeferred +import java.io.File +import java.net.URLDecoder +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import kotlin.io.path.Path +import kotlin.io.path.pathString + + +/** + * Installs a custom X509 TrustManager that accepts all certificates. Future + * HTTPS calls will therefore not check the validity of the TLS certificate. + * + * DO NOT CONFIGURE THE DOWNLOADER TO BYPASS CERTIFICATE VALIDATION IN RELEASE + */ +fun acceptUntrustedCertificates() { + try { + // Create a trust manager that does not validate certificate chains + val trustAllCerts = arrayOf( + @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted( + chain: Array, + authType: String + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted( + chain: Array, + authType: String + ) { + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + }) + + // Install the all-trusting trust manager + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + // Create an ssl socket factory with our all-trusting manager + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) + Log.w( + BDPlugin.TAG, "Bypassing TLS certificate validation\n" + + "HTTPS calls will NOT check the validity of the TLS certificate." + ) + } catch (e: Exception) { + throw RuntimeException(e) + } +} + +/** + * Returns true if there is insufficient space to store a file of length + * [contentLength] + * + * Returns false if [contentLength] <= 0 + * Returns false if configCheckAvailableSpace has not been set, or if available + * space is greater than that setting + * Returns true otherwise + */ +fun insufficientSpace(applicationContext: Context, contentLength: Long): Boolean { + if (contentLength <= 0) { + return false + } + val checkValue = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getInt(BDPlugin.keyConfigCheckAvailableSpace, 0) + if (checkValue <= 0) { + return false + } + val path = Environment.getDataDirectory() + val stat = StatFs(path.path) + val available = stat.blockSizeLong * stat.availableBlocksLong + return available - (BDPlugin.remainingBytesToDownload.values.sum() + + contentLength) < (checkValue.toLong() shl 20) +} + +/** + * Parses the range in a Range header, and returns a Pair representing + * the range. The format needs to be "bytes=10-20" + * + * A missing lower range is substituted with 0L, and a missing upper + * range with null. If the string cannot be parsed, returns (0L, null) + */ +fun parseRange(rangeStr: String): Pair { + val regex = Regex("""bytes=(\d*)-(\d*)""") + val match = regex.find(rangeStr) ?: return Pair(0, null) + val start = match.groupValues[1].toLongOrNull() ?: 0L + val end = match.groupValues[2].toLongOrNull() + return Pair(start, end) +} + +/** + * Returns the content length extracted from the [responseHeaders], or from + * the [task] headers + */ +fun getContentLength(responseHeaders: Map>, task: Task): Long { + // if response provides contentLength, return it + val contentLength = responseHeaders["Content-Length"]?.get(0)?.toLongOrNull() + ?: responseHeaders["content-length"]?.get(0)?.toLongOrNull() + ?: -1L + if (contentLength != -1L) { + return contentLength + } + // try extracting it from Range header + val taskRangeHeader = task.headers["Range"] ?: task.headers["range"] ?: "" + val taskRange = parseRange(taskRangeHeader) + if (taskRange.second != null) { + val rangeLength = taskRange.second!! - taskRange.first + 1L + Log.d(TAG, "TaskId ${task.taskId} contentLength set to $rangeLength based on Range header") + return rangeLength + } + // try extracting it from a special "Known-Content-Length" header + val knownLength = (task.headers["Known-Content-Length"]?.toLongOrNull() + ?: task.headers["known-content-length"]?.toLongOrNull() + ?: -1) + if (knownLength != -1L) { + Log.d( + TAG, + "TaskId ${task.taskId} contentLength set to $knownLength based on Known-Content-Length header" + ) + } else { + Log.d(TAG, "TaskId ${task.taskId} contentLength undetermined") + } + return knownLength +} + + +/** + * Return the path to the baseDir for this [baseDirectory], or null if path could not be reached + * + * Null only happens if external storage is requested but not available + */ +fun baseDirPath(context: Context, baseDirectory: BaseDirectory): String? { + val useExternalStorage = PreferenceManager.getDefaultSharedPreferences(context) + .getInt(BDPlugin.keyConfigUseExternalStorage, -1) == 0 + val baseDirPath: String + if (!useExternalStorage) { + if (Build.VERSION.SDK_INT >= 26) { + baseDirPath = when (baseDirectory) { + BaseDirectory.applicationDocuments -> Path( + context.dataDir.path, "app_flutter" + ).pathString + + BaseDirectory.temporary -> context.cacheDir.path + BaseDirectory.applicationSupport -> context.filesDir.path + BaseDirectory.applicationLibrary -> Path( + context.filesDir.path, "Library" + ).pathString + + BaseDirectory.root -> "" + } + } else { + baseDirPath = when (baseDirectory) { + BaseDirectory.applicationDocuments -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + "${context.dataDir.path}/app_flutter" + } else { + "${context.applicationInfo.dataDir}/app_flutter" + } + + BaseDirectory.temporary -> context.cacheDir.path + BaseDirectory.applicationSupport -> context.filesDir.path + BaseDirectory.applicationLibrary -> "${context.filesDir.path}/Library" + BaseDirectory.root -> "" + } + } + } else { + // external storage variant + val externalStorageDirectory = context.getExternalFilesDir(null) + val externalCacheDirectory = context.externalCacheDir + if (externalStorageDirectory == null || externalCacheDirectory == null) { + Log.e(TAG, "Could not access external storage") + return null + } + baseDirPath = when (baseDirectory) { + BaseDirectory.applicationDocuments -> externalStorageDirectory.path + BaseDirectory.temporary -> externalCacheDirectory.path + BaseDirectory.applicationSupport -> "${externalStorageDirectory.path}/Support" + BaseDirectory.applicationLibrary -> "${externalStorageDirectory.path}/Library" + BaseDirectory.root -> "" + } + } + return baseDirPath +} + +fun getBasenameWithoutExtension(file: File): String { + val fileName = file.name + val extension = file.extension + return fileName.substringBeforeLast(".$extension") +} + +/** + * Returns the Uri for the given [filePath], or null if not allowed/possible + */ +fun getUriFromFilePath(context: Context, filePath: String): String? { + try { + val contentUri = getUriForFile( + context, + context.packageName + ".com.bbflight.background_downloader.fileprovider", + File(filePath) + ) + return contentUri.toString() + } catch (e: Exception) { + Log.i(BDPlugin.TAG, "Failed to get Uri for file $filePath: $e") + return null + } +} + +/** + * Suggests a filename based on response headers and a URL. If none can be derived, returns "" + * + * @param responseHeaders The response headers map. + * @param url The URL the file would be downloaded from. + * @return A suggested filename, derived from the headers or the URL. + */ +fun suggestFilename(responseHeaders: Map>, url: String): String { + try { + val disposition = (responseHeaders["Content-Disposition"] + ?: responseHeaders["content-disposition"])?.get(0) + if (disposition != null) { + // Try filename*=UTF-8'language'"encodedFilename" + val encodedFilenameRegEx = + Regex("""filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?""", RegexOption.IGNORE_CASE) + var match = encodedFilenameRegEx.find(disposition) + if (match != null && match.groupValues[1].isNotEmpty() && match.groupValues[3].isNotEmpty()) { + try { + val suggestedFilename = if (match.groupValues[1].uppercase() == "UTF-8") { + URLDecoder.decode(match.groupValues[3], "UTF-8") + } else { + match.groupValues[3] + } + return suggestedFilename + } catch (_: IllegalArgumentException) { + Log.d( + TAG, + "Could not interpret suggested filename (UTF-8 url encoded) ${match.groupValues[3]}" + ) + } + } + // Try filename="filename" + val plainFilenameRegEx = + Regex("""filename=\s*"?([^"]+)"?.*$""", RegexOption.IGNORE_CASE) + match = plainFilenameRegEx.find(disposition) + if (match != null && match.groupValues[1].isNotEmpty()) { + return match.groupValues[1] + } + } + } catch (_: Throwable) { + } + // Try filename derived from last path segment of the url + try { + val uri = Uri.parse(url) + return getFilenameFromUri(uri) + } catch (_: Throwable) { + } + return "" // Default fallback +} + +/** + * Returns the filename from the given [uri] + * + * Returns "" if not possible + * + * Slightly modified from lastPathSegment to account for possible subdirs in that last segment + * (e.g. '/primary:Documents/dog-drawing.jpg' will have 'Documents/dog-drawing.jpg' as last path + * segment, not 'dog-drawing.jpg') + */ +fun getFilenameFromUri(uri: Uri): String { + val lastPathSegment = uri.lastPathSegment + if (lastPathSegment != null) { + val lastSlashIndex = lastPathSegment.lastIndexOf("/") + val filename = + if (lastSlashIndex != -1) lastPathSegment.substring(lastSlashIndex + 1) else lastPathSegment + return filename + } + return "" +} + +// Helper extension to filter out null values from Maps +fun Map?.filterNotNull(): Map { + return this?.filter { (key, value) -> key != null && value != null } ?: emptyMap() +} + +/** + * Simple Flutter result handler, completes the [completer] with true if the result of the + * method call is true, and false otherwise + */ +class FlutterBooleanResultHandler(private val completer: CompletableDeferred) : + MethodChannel.Result { + + override fun success(result: Any?) { + completer.complete(result == true) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + Log.i(BDPlugin.TAG, "Flutter result error $errorCode: $errorMessage") + completer.complete(false) + } + + override fun notImplemented() { + Log.i(BDPlugin.TAG, "Flutter method not implemented") + completer.complete(false) + } +} + +/** + * Flutter result handler, completes the [completer] with the result of the method call + */ +class FlutterResultHandler(private val completer: CompletableDeferred) : + MethodChannel.Result { + + override fun success(result: Any?) { + @Suppress("UNCHECKED_CAST") + val castedResult = result as? T? + completer.complete(castedResult) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + Log.i(BDPlugin.TAG, "Flutter result error $errorCode: $errorMessage") + completer.complete(null) + } + + override fun notImplemented() { + Log.i(BDPlugin.TAG, "Flutter method not implemented") + completer.complete(null) + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/HoldingQueue.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/HoldingQueue.kt new file mode 100644 index 0000000..8ec5bda --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/HoldingQueue.kt @@ -0,0 +1,398 @@ +package com.bbflight.background_downloader + +import android.content.Context +import android.util.Log +import androidx.preference.PreferenceManager +import androidx.work.WorkManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.atomic.AtomicInteger +import android.app.job.JobScheduler +import android.os.Build +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Queue that holds [EnqueueItem] items before they are actually enqueued as a WorkManager job + * + * Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items + * can be enqueued simultaneously. + * + * Call: + * [add] to add an [EnqueueItem] + * [taskFinished] for all tasks that finish, so we may start a new one + * [cancelAllTasks] to empty the queue (sends status updates) + * [cancelTasksWithIds] to remove specific tasks (sends status updates) + * [allTasks] for a list of [Task] matching a group + * [taskForId] to get the task for a specific taskId + */ +class HoldingQueue(private val context: Context, private val workManager: WorkManager) { + + var maxConcurrent: Int = 1 shl 20 + var maxConcurrentByHost: Int = 1 shl 20 + var maxConcurrentByGroup: Int = 1 shl 20 + val hostByTaskId = ConcurrentHashMap() + val enqueuedTaskIds: MutableSet = ConcurrentHashMap.newKeySet() + + private val concurrent = AtomicInteger(0) + // so each slot is released only once. Guarded by [stateMutex] + private val taskIdsHoldingSlot = HashSet() + private val concurrentByHost = ConcurrentHashMap() + private val concurrentByGroup = ConcurrentHashMap() + + private val queue = PriorityBlockingQueue() + private val taskFinishedQueue = Channel(capacity = Channel.UNLIMITED) + + + private var job: Job? = null // timer job + private val scope = CoroutineScope(Dispatchers.Default) + private val processSignal = Channel(Channel.UNLIMITED) + val stateMutex = Mutex() + + init { + // coroutine to process the queue + scope.launch { + for (signal in processSignal) { // signal comes from [advanceQueue] + stateMutex.withLock { + val mustWait = ArrayList() + while (concurrent.get() < maxConcurrent && queue.isNotEmpty()) { + val item = queue.poll() ?: break + val host = item.task.host() + val group = item.task.group + // a task re-enqueued while its canceled run is still ending waits for + // that run to release its slot, so two runs of a task never overlap + if (!taskIdsHoldingSlot.contains(item.task.taskId) && (concurrentByHost[host]?.get() + ?: 0) < maxConcurrentByHost && (concurrentByGroup[group]?.get() + ?: 0) < maxConcurrentByGroup + ) { + // enqueue this item after incrementing counters + concurrent.incrementAndGet() + concurrentByHost.computeIfAbsent(host) { AtomicInteger(0) }.incrementAndGet() + concurrentByGroup.computeIfAbsent(group) { AtomicInteger(0) }.incrementAndGet() + taskIdsHoldingSlot.add(item.task.taskId) + item.enqueue(afterDelayMillis = 0) + } else { + // this item has to wait + mustWait.add(item) + } + } + // add mustWait items back to the queue + queue.addAll(mustWait) + } + } + } + + // coroutine to process 'taskFinished' messages + scope.launch { + for (task in taskFinishedQueue) { + executeTaskFinished(task) + } + } + + // initial state calculation + scope.launch { + calculateState() + } + } + + /** + * Add [EnqueueItem] [item] to the queue and advance the queue if possible + */ + suspend fun add(item: EnqueueItem) { + stateMutex.withLock { + queue.add(item) + enqueuedTaskIds.add(item.task.taskId) + NotificationService.registerEnqueue( + item, + success = true + ) // for group notification count + } + advanceQueue() + } + + /** + * Signals to the holdingQueue that a [task] has finished + * + * The processing is done async, so this method only adds + * the task to the processing queue + */ + suspend fun taskFinished(task: Task) { + taskFinishedQueue.send(task) + } + + /** + * Removes all [EnqueueItem] where their taskId is in [taskIds], sends a + * [TaskStatus.canceled] update and returns a list of + * taskIds that were cancelled this way + * + * Because this is used in combination with the WorkManager tasks, use of this method + * requires the caller to acquire the [stateMutex] + */ + suspend fun cancelTasksWithIds(context: Context, taskIds: Iterable): List { + val removedTaskIds: List + val toRemove = queue.filter { taskIds.contains(it.task.taskId) } + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + toRemove.forEach { + queue.remove(it) + TaskWorker.processStatusUpdate(it.task, TaskStatus.canceled, prefs, context = context) + if (it.notificationConfigJsonString != null) { + NotificationService.createUpdateNotificationWorker( + context, + bdJson.encodeToString(it.task), + it.notificationConfigJsonString, + TaskStatus.canceled.ordinal + ) + } + Log.i(BDPlugin.TAG, "Canceled task with id ${it.task.taskId}") + } + removedTaskIds = toRemove.map { it.task.taskId }.toMutableList() + enqueuedTaskIds.removeAll(removedTaskIds.toSet()) + removedTaskIds.forEach { hostByTaskId.remove(it) } + return removedTaskIds + } + + /** + * Cancel (delete) all [EnqueueItem] matching [group], send a + * [TaskStatus.canceled] for each and return the number of items cancelled + * + * Because this is used in combination with the WorkManager tasks, use of this method + * requires the caller to acquire the [stateMutex] + */ + suspend fun cancelAllTasks(context: Context, group: String): Int { + val taskIds = + queue.filter { it.task.group == group }.map { it.task.taskId }.toMutableList() + cancelTasksWithIds(context, taskIds) + return taskIds.size + } + + /** + * Return task matching [taskId], or null + * + * Because this is used in combination with the WorkManager tasks, use of this method + * requires the caller to acquire the [stateMutex] + */ + fun taskForId(taskId: String): Task? { + val tasks = queue.filter { it.task.taskId == taskId }.map { it.task } + if (tasks.isNotEmpty()) { + return tasks.first() + } + return null + } + + /** + * Return list of [Task] for this [group]. If [group] is null, all tasks will be returned + * + * Because this is used in combination with the WorkManager tasks, use of this method + * requires the caller to acquire the [stateMutex] + */ + fun allTasks(group: String?): List { + return queue.filter { group == null || it.task.group == group }.map { it.task } + } + + /** + * Advance the queue by signalling the queue processing coroutine + * + * Also restarts a timer that will advance the queue in 10 seconds, in case + * it dries up + */ + private fun advanceQueue() { + processSignal.trySend(Unit) + if (queue.isNotEmpty()) { + advanceQueueInFuture() + } + } + + /** + * Calls [advanceQueue] in 10 seconds, thus ensuring it never dries up + * + * If called again before the timer fires, cancels the original timer and + * resets to 10 seconds + */ + private fun advanceQueueInFuture() { + job?.cancel() + job = scope.launch { + delay(10000) + advanceQueue() + } + } + + /** + * Signals to the holdingQueue that a [task] has finished + * + * Adjusts the state variables and advances the queue + */ + private suspend fun executeTaskFinished(task: Task) { + hostByTaskId.remove(task.taskId) + enqueuedTaskIds.remove(task.taskId) + val host = task.host() + val group = task.group + stateMutex.withLock { + if (taskIdsHoldingSlot.remove(task.taskId)) { + concurrent.decrementAndGet() + concurrentByHost[host]?.decrementAndGet() + concurrentByGroup[group]?.decrementAndGet() + } + } + advanceQueue() + } + + /** + * Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values + * + * This is expensive, so is only done initially and when the [advanceQueueInFuture] timer + * fires + */ + private suspend fun calculateState() { + stateMutex.withLock { + val workInfos = workManager.getWorkInfosByTag(BDPlugin.TAG).get() + .filter { !it.state.isFinished } + var totalCount = 0 + concurrentByHost.clear() + concurrentByGroup.clear() + taskIdsHoldingSlot.clear() + for (workInfo in workInfos) { + // update concurrentByHost + try { + val taskIdTag = workInfo.tags.first { it.startsWith("taskId=") } + val taskId = taskIdTag.substring(startIndex = 7) + if (!taskIdsHoldingSlot.add(taskId)) { + // a task holds one slot, however many unfinished works it has + continue + } + val host = hostByTaskId[taskId] ?: "" + if (!concurrentByHost.containsKey(host)) { + concurrentByHost[host] = AtomicInteger(0) + } + concurrentByHost[host]?.incrementAndGet() + } catch (_: NoSuchElementException) { + } + totalCount++ + // update concurrentByGroup + try { + val groupTag = workInfo.tags.first { it.startsWith("group=") } + val group = groupTag.substring(startIndex = 6) + if (!concurrentByGroup.containsKey(group)) { + concurrentByGroup[group] = AtomicInteger(0) + } + concurrentByGroup[group]?.incrementAndGet() + } catch (_: NoSuchElementException) { + } + } + // JobScheduler (UIDT) + if (Build.VERSION.SDK_INT >= 34) { + val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val jobs = jobScheduler.allPendingJobs + for (job in jobs) { + if (job.service.className == UIDTJobService::class.java.name) { + val taskJson = job.extras.getString(TaskWorker.keyTask) + if (taskJson != null) { + try { + val task = bdJson.decodeFromString(taskJson) + if (!taskIdsHoldingSlot.add(task.taskId)) { + continue + } + totalCount++ + + val host = task.host() + val group = task.group + + if (!concurrentByHost.containsKey(host)) { + concurrentByHost[host] = AtomicInteger(0) + } + concurrentByHost[host]?.incrementAndGet() + + if (!concurrentByGroup.containsKey(group)) { + concurrentByGroup[group] = AtomicInteger(0) + } + concurrentByGroup[group]?.incrementAndGet() + } catch (e: Exception) { + Log.w(BDPlugin.TAG, "Could not decode task from job info: $e") + } + } + } + } + } + concurrent.set(totalCount) + } + } +} + + +/** + * Holds data related to enqueueing a task + * + * Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued) + * and in the context of the [HoldingQueue] + */ +class EnqueueItem( + val context: Context, + val task: Task, + val notificationConfigJsonString: String?, + private val resumeData: ResumeData? = null, + private val plugin: BDPlugin? = null, + private val created: Date = Date() +) : Comparable { + + /** Execute the re-enqueue after an appropriate delay */ + suspend fun enqueue(afterDelayMillis: Int = 1000) { + delayUntil(afterDelayMillis) + if (!BDPlugin.doEnqueue( + context = context, + task = task, + notificationConfigJsonString = notificationConfigJsonString, + resumeData = resumeData, + plugin = plugin + ) + ) { + Log.w(BDPlugin.TAG, "Delayed or retried enqueue failed for taskId ${task.taskId}") + TaskWorker.processStatusUpdate( + task, TaskStatus.failed, PreferenceManager.getDefaultSharedPreferences(context), + taskException = TaskException( + type = ExceptionType.general, + description = "Delayed or retried enqueue failed" + ), context = context + ) + BDPlugin.holdingQueue?.taskFinished(task) + NotificationService.registerEnqueue(this, success = false) + } + delay(20) + } + + /** + * Re-enqueue after [afterDelayMillis] since creation, through the [HoldingQueue] if there is + * one, so the task waits for a slot like any other + */ + suspend fun reEnqueue(afterDelayMillis: Int = 1000) { + val holdingQueue = BDPlugin.holdingQueue + if (holdingQueue == null) { + enqueue(afterDelayMillis) + } else { + delayUntil(afterDelayMillis) + Log.i(BDPlugin.TAG, "Re-enqueueing task with id ${task.taskId} to the HoldingQueue") + holdingQueue.add(this) + } + } + + private suspend fun delayUntil(afterDelayMillis: Int) { + val timeSinceCreatedMillis = Date().time - created.time + if (timeSinceCreatedMillis < afterDelayMillis) { + delay(afterDelayMillis - timeSinceCreatedMillis) + } + } + + /** + * Items are sorted based on task priority first, then by task creation time + */ + override fun compareTo(other: EnqueueItem) = compareValuesBy(this, other, + { it.task.priority }, + { it.task.creationTime }) +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Models.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Models.kt new file mode 100644 index 0000000..ff9daa5 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Models.kt @@ -0,0 +1,607 @@ +@file:Suppress("EnumEntryName") + +package com.bbflight.background_downloader + +import android.content.Context +import android.net.Uri +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure +import kotlinx.serialization.json.Json +import java.io.File +import java.net.MalformedURLException +import java.net.URL +import kotlin.math.absoluteValue +import kotlin.random.Random + +val bdJson = Json { + ignoreUnknownKeys = true + isLenient = true +} + + +/// Base directory in which files will be stored, based on their relative +/// path. +/// +/// These correspond to the directories provided by the path_provider package +@Serializable(with = BaseDirectorySerializer::class) +enum class BaseDirectory { + applicationDocuments, // getApplicationDocumentsDirectory() + temporary, // getTemporaryDirectory() + applicationSupport, // getApplicationSupportDirectory() + applicationLibrary, // getApplicationSupportDirectory() subdir "Library" + root // system root directory +} + +class BaseDirectorySerializer : EnumAsIntSerializer( + "BaseDirectory", + { it.ordinal }, + { v -> BaseDirectory.entries.first { it.ordinal == v } } +) + +/// Type of updates requested for a group of tasks +@Serializable(with = UpdatesSerializer::class) +enum class Updates { + none, // no status or progress updates + status, // only calls upon change in DownloadTaskStatus + progress, // only calls for progress + statusAndProgress // calls also for progress along the way +} + +class UpdatesSerializer : EnumAsIntSerializer( + "Updates", + { it.ordinal }, + { v -> Updates.entries.first { it.ordinal == v } } +) + +/** + * Holds various options related to the task that are not included in the + * task's properties, as they are rare + */ +@OptIn(InternalSerializationApi::class) +@Serializable +class TaskOptions( + private val beforeTaskStartRawHandle: Long?, + private val onTaskStartRawHandle: Long?, + private val onTaskFinishedRawHandle: Long?, + var auth: Auth? +) { + + fun hasBeforeStartCallback(): Boolean = beforeTaskStartRawHandle != null + + fun hasOnStartCallback(): Boolean = onTaskStartRawHandle != null + + fun hasOnFinishCallback(): Boolean = onTaskFinishedRawHandle != null +} + +/** + * The Dart side Task + * + * A blend of UploadTask, DownloadTask and ParallelDownloadTask with [taskType] indicating what kind + * of task this is + */ +@Suppress("SameParameterValue") +@OptIn(InternalSerializationApi::class) +@Serializable +class Task( + val taskId: String = "${Random.nextInt().absoluteValue}", + val url: String, + val urls: List = listOf(), + val filename: String, + val headers: Map, + val httpRequestMethod: String = "GET", + val chunks: Int = 1, + val post: String? = null, + val postIsBinary: Boolean = false, + val fileField: String = "", + val mimeType: String = "", + val fields: Map = mapOf(), + val directory: String = "", + val baseDirectory: BaseDirectory, + val group: String, + val updates: Updates, + val requiresWiFi: Boolean = false, + val retries: Int = 0, + var retriesRemaining: Int = 0, + val allowPause: Boolean = false, + val priority: Int = 5, + val metaData: String = "", + val displayName: String = "", + val creationTime: Long = System.currentTimeMillis(), // untouched, so kept as integer on Android side + val options: TaskOptions? = null, + val transferHints: List? = null, + val stallTimeout: Long? = null, + val taskType: String +) { + + /** + * Returns a copy of the [Task] with optional changes to specific fields + */ + fun copyWith( + taskId: String? = null, + url: String? = null, + urls: List? = null, + filename: String? = null, + headers: Map? = null, + httpRequestMethod: String? = null, + chunks: Int? = null, + post: String? = null, + fileField: String? = null, + mimeType: String? = null, + fields: Map? = null, + directory: String? = null, + baseDirectory: BaseDirectory? = null, + group: String? = null, + updates: Updates? = null, + requiresWiFi: Boolean? = null, + retries: Int? = null, + retriesRemaining: Int? = null, + allowPause: Boolean? = null, + priority: Int? = null, + metaData: String? = null, + displayName: String? = null, + creationTime: Long? = null, + options: TaskOptions? = null, + transferHints: List? = null, + stallTimeout: Long? = null, + taskType: String? = null + ): Task { + return Task( + taskId = taskId ?: this.taskId, + url = url ?: this.url, + urls = urls ?: this.urls, + filename = filename ?: this.filename, + headers = headers ?: this.headers, + httpRequestMethod = httpRequestMethod ?: this.httpRequestMethod, + chunks = chunks ?: this.chunks, + post = post ?: this.post, + postIsBinary = this.postIsBinary, + fileField = fileField ?: this.fileField, + mimeType = mimeType ?: this.mimeType, + fields = fields ?: this.fields, + directory = directory ?: this.directory, + baseDirectory = baseDirectory ?: this.baseDirectory, + group = group ?: this.group, + updates = updates ?: this.updates, + requiresWiFi = requiresWiFi ?: this.requiresWiFi, + retries = retries ?: this.retries, + retriesRemaining = retriesRemaining ?: this.retriesRemaining, + allowPause = allowPause ?: this.allowPause, + priority = priority ?: this.priority, + metaData = metaData ?: this.metaData, + displayName = displayName ?: this.displayName, + creationTime = creationTime ?: this.creationTime, + options = options ?: this.options, + transferHints = transferHints ?: this.transferHints, + stallTimeout = stallTimeout ?: this.stallTimeout, + taskType = taskType ?: this.taskType + ) + } + + /** True if this task expects to provide progress updates */ + fun providesProgressUpdates(): Boolean { + return updates == Updates.progress || + updates == Updates.statusAndProgress + } + + /** True if this task expects to provide status updates */ + fun providesStatusUpdates(): Boolean { + return updates == Updates.status || + updates == Updates.statusAndProgress + } + + /** True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask */ + fun isDownloadTask(): Boolean { + return taskType == "DownloadTask" || taskType == "UriDownloadTask" || taskType == "ParallelDownloadTask" + } + + /** True if this task is an UploadTask or MultiUploadTask */ + fun isUploadTask(): Boolean { + return taskType == "UploadTask" || taskType == "UriUploadTask" || taskType == "MultiUploadTask" + } + + /** True if this task is a ParallelDownloadTask */ + fun isParallelDownloadTask(): Boolean { + return taskType == "ParallelDownloadTask" + } + + /** True if this task is a MultiUploadTask */ + private fun isMultiUploadTask(): Boolean { + return taskType == "MultiUploadTask" + } + + /** True if this task is a DataTask */ + fun isDataTask(): Boolean { + return taskType == "DataTask" + } + + + /** + * Returns full path (String) to the file, + * based on [withFilename] or the [Task.filename] (default) + * + * If the task is a MultiUploadTask and no [withFilename] is given, + * returns the empty string, as there is no single path that can be + * returned. + */ + fun filePath(context: Context, withFilename: String? = null): String { + if (isMultiUploadTask() && withFilename == null) { + return "" + } + val filenameToUse = withFilename ?: filename + val baseDirPath = baseDirPath(context, baseDirectory) + ?: throw IllegalStateException("External storage is requested but not available") + return if (directory.isEmpty()) "$baseDirPath/${filenameToUse}" else + "$baseDirPath/${directory}/${filenameToUse}" + } + + /** + * Returns a copy of the task with the [Task.filename] property changed + * to the filename suggested by the server, or derived from the url, or + * unchanged. + * + * If [unique] is true, the filename is guaranteed not to already exist. This + * is accomplished by adding a suffix to the suggested filename with a number, + * e.g. "data (2).txt" + * + * The server-suggested filename is obtained from the [responseHeaders] entry + * "Content-Disposition" + */ + fun withSuggestedFilenameFromResponseHeaders( + context: Context, + responseHeaders: MutableMap>, + unique: Boolean = false + ): Task { + // Returns [Task] with a filename similar to the one + // supplied, but unused. + // + // If [unique], filename will sequence up in "filename (8).txt" format, + // otherwise returns the [task] + fun taskWithUniqueFilename(task: Task, unique: Boolean): Task { + if (!unique) { + return task + } + val sequenceRegEx = Regex("""\((\d+)\)\.?[^.]*$""") + val extensionRegEx = Regex("""\.[^.]*$""") + var newTask = task + var filePath = task.filePath(context) + var exists = File(filePath).exists() + while (exists) { + val extension = extensionRegEx.find(newTask.filename)?.value ?: "" + val match = sequenceRegEx.find(newTask.filename) + val newSequence = (match?.groupValues?.get(1)?.toIntOrNull() ?: 0) + 1 + val newFilename = when (match) { + null -> "${getBasenameWithoutExtension(File(newTask.filename))} ($newSequence)$extension" + else -> "${ + newTask.filename.substring( + 0, + match.range.first - 1 + ) + } ($newSequence)$extension" + } + newTask = newTask.copyWith(filename = newFilename) + filePath = newTask.filePath(context) + exists = File(filePath).exists() + } + return newTask + } + + // start of main method + val suggestedFilename = suggestFilename(responseHeaders, url) + return taskWithUniqueFilename( + this.copyWith(filename = if (suggestedFilename.isNotEmpty()) suggestedFilename else this.filename), + unique + ) + } + + /** + * Returns a list of fileData elements, one for each file to upload. + * Each element is a triple containing fileField, full filePath or Uri, mimeType + * + * The lists are stored in the similarly named String fields as a JSON list, + * with each list the same length. For the filenames list, if a filename refers + * to a file that exists (i.e. it is a full path) then that is the filePath used, + * otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory] + * to form a full file path. If instead the filePath is given as a Uri string, then + * that string is used + */ + fun extractFilesData(context: Context): List> { + val fileFields = bdJson.decodeFromString>(fileField) + val filenames = bdJson.decodeFromString>(filename) + val mimeTypes = bdJson.decodeFromString>(mimeType) + val result = ArrayList>() + for (i in fileFields.indices) { + val filenameOrPathOrUriString = filenames[i] + val maybeUri = Uri.parse(filenameOrPathOrUriString) + if (maybeUri.scheme == "content" || maybeUri.scheme == "file") { + // for Uri, keep the Uri string + result.add(Triple(fileFields[i], filenameOrPathOrUriString, mimeTypes[i])) + } else { + // For filePath, check if full path or construct it + if (File(filenameOrPathOrUriString).exists()) { + result.add(Triple(fileFields[i], filenameOrPathOrUriString, mimeTypes[i])) + } else { + result.add( + Triple( + fileFields[i], + filePath(context, withFilename = filenameOrPathOrUriString), + mimeTypes[i] + ) + ) + } + } + } + return result + } + + /** + * Return the hos of the url in this task + */ + fun host() = try { + URL(url).host + } catch (e: MalformedURLException) { + "" + } + + /** True if this task has a filename + * False if it has only a URI, or if the filename is '?' + */ + fun hasFilename(): Boolean { + val filename = UriUtils.unpack(filename).first + return filename != null && filename != "?" + } + + + override fun toString(): String { + return "Task(taskId='$taskId', url='$url', filename='$filename', headers=$headers, httpRequestMethod=$httpRequestMethod, post=$post, fileField='$fileField', mimeType='$mimeType', fields=$fields, directory='$directory', baseDirectory=$baseDirectory, group='$group', updates=$updates, requiresWiFi=$requiresWiFi, retries=$retries, retriesRemaining=$retriesRemaining, allowPause=$allowPause, priority=$priority, metaData='$metaData', displayName='$displayName', creationTime=$creationTime, options=$options, transferHints=$transferHints, stallTimeout=$stallTimeout, taskType='$taskType')" + } + + /** + * An equality test on a [Task] is a test on the [taskId] only + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Task + + return taskId == other.taskId + } + + override fun hashCode(): Int { + return taskId.hashCode() + } +} + +/** Defines a set of possible states which a [Task] can be in. + * + * Must match the Dart equivalent enum, as value are passed as ordinal/index integer + */ +@Serializable(with = TaskStatusSerializer::class) +enum class TaskStatus { + enqueued, + running, + complete, + notFound, + failed, + canceled, + waitingToRetry, + paused; + + private fun isNotFinalState(): Boolean { + return this == enqueued || this == running || this == waitingToRetry || this == paused + } + + fun isFinalState(): Boolean { + return !isNotFinalState() + } +} + +class TaskStatusSerializer : EnumAsIntSerializer( + "TaskStatus", + { it.ordinal }, + { v -> TaskStatus.entries.first { it.ordinal == v } } +) + +/** Holds data associated with a task status update + * + * Stored locally in JSON format if posting on background channel fails, + * otherwise getter [argList] is used to extract the list of arguments + * to be passed to the background channel as arguments to the "statusUpdate" method invocation + */ +@Serializable +@OptIn(InternalSerializationApi::class) +data class TaskStatusUpdate( + val task: Task, + val taskStatus: TaskStatus, // note Dart field name is 'status' + val exception: TaskException?, + val responseBody: String?, + val responseStatusCode: Int?, + val responseHeaders: Map?, + val mimeType: String?, + val charSet: String? +) { + + /** + * Returns the list of arguments that represents this [TaskStatusUpdate] when posting + * a "statusUpdate" on the backgroundChannel + * + * Included data differs between failed tasks and other status updates + */ + val argList + get() = if (taskStatus == TaskStatus.failed) mutableListOf( + taskStatus.ordinal, + exception?.type?.typeString, + exception?.description, + exception?.httpResponseCode, + responseBody + ) else mutableListOf( + taskStatus.ordinal, + if (taskStatus.isFinalState()) responseBody else null, + if (taskStatus.isFinalState()) responseHeaders else null, + if (taskStatus == TaskStatus.complete || taskStatus == TaskStatus.notFound) responseStatusCode else null, + if (taskStatus.isFinalState()) mimeType else null, + if (taskStatus.isFinalState()) charSet else null + ) + + override fun toString(): String { + return "TaskStatusUpdate(task=$task, taskStatus=$taskStatus, exception=$exception, responseBody=$responseBody, responseStatusCode=$responseStatusCode, responseHeaders=$responseHeaders, mimeType=$mimeType, charSet=$charSet)" + } +} + +/** Holds data associated with a task progress update, for local storage */ +@Serializable +@OptIn(InternalSerializationApi::class) +data class TaskProgressUpdate(val task: Task, val progress: Double, val expectedFileSize: Long) + +/// Holds data associated with a resume +@Serializable +@OptIn(InternalSerializationApi::class) +data class ResumeData( + val task: Task, + val data: String, + val requiredStartByte: Long, + val eTag: String? +) + +/** + * The type of a [TaskException] + * + * Exceptions are handled differently on the Kotlin side because they are only a vehicle to message + * to the Flutter side. An exception class hierarchy is therefore not required in Kotlin, and the + * single [TaskException] class has a field for the [TaskException.type] of exception, as well as all possible + * exception fields. + * The [TaskException.type] (as a String using the enum's [ExceptionType.typeString]) is used on the + * Flutter side to create the appropriate Exception subclass. + */ +enum class ExceptionType(val typeString: String) { + /// General error + general("TaskException"), + + /// Could not save or find file, or create directory + fileSystem("TaskFileSystemException"), + + /// URL incorrect + url("TaskUrlException"), + + /// Connection problem, eg host not found, timeout + connection("TaskConnectionException"), + + /// Could not resume or pause task + resume("TaskResumeException"), + + /// Invalid HTTP response + httpResponse("TaskHttpException") +} + +/** + * Contains error information associated with a failed [Task] + * + * The [type] categorizes the exception, used to create the appropriate subclass on the Flutter side + * The [httpResponseCode] is only valid if >0 and may offer details about the + * nature of the error + * The [description] is typically taken from the platform-generated + * error message, or from the plugin. The localization is undefined + */ +@Serializable(with = TaskExceptionSerializer::class) +class TaskException( + val type: ExceptionType, + val httpResponseCode: Int = -1, + val description: String = "" +) + +object TaskExceptionSerializer : KSerializer { + override val descriptor = buildClassSerialDescriptor("TaskException") { + element("type") + element("httpResponseCode") + element("description") + } + + override fun serialize(encoder: Encoder, value: TaskException) { + encoder.encodeStructure(descriptor) { + encodeStringElement( + descriptor, 0, when (value.type) { + ExceptionType.fileSystem -> "TaskFileSystemException" + ExceptionType.url -> "TaskUrlException" + ExceptionType.connection -> "TaskConnectionException" + ExceptionType.resume -> "TaskResumeException" + ExceptionType.httpResponse -> "TaskHttpException" + else -> "TaskException" + } + ) + encodeIntElement(descriptor, 1, value.httpResponseCode) + encodeStringElement(descriptor, 2, value.description) + } + } + + override fun deserialize(decoder: Decoder): TaskException { + return decoder.decodeStructure(descriptor) { + var type: ExceptionType? = null + var httpResponseCode = -1 + var description = "" + + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> type = when (decodeStringElement(descriptor, 0)) { + "TaskFileSystemException" -> ExceptionType.fileSystem + "TaskUrlException" -> ExceptionType.url + "TaskConnectionException" -> ExceptionType.connection + "TaskResumeException" -> ExceptionType.resume + "TaskHttpException" -> ExceptionType.httpResponse + else -> ExceptionType.general + } + + 1 -> httpResponseCode = decodeIntElement(descriptor, 1) + 2 -> description = decodeStringElement(descriptor, 2) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + + TaskException(type!!, httpResponseCode, description) + } + } +} + + +/** + * Wifi requirement modes at the application level + */ +enum class RequireWiFi { + asSetByTask, + forAllTasks, + forNoTasks +} + +/** + * Serializer for enums, such that they are encoded as an Int representing + * the ordinal (index) of the value, instead of the String representation of + * the value. + */ +open class EnumAsIntSerializer>( + serialName: String, + val serialize: (v: T) -> Int, + val deserialize: (v: Int) -> T +) : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: T) { + encoder.encodeInt(serialize(value)) + } + + override fun deserialize(decoder: Decoder): T { + val v = decoder.decodeInt() + return deserialize(v) + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Notifications.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Notifications.kt new file mode 100644 index 0000000..b788dea --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/Notifications.kt @@ -0,0 +1,1155 @@ +package com.bbflight.background_downloader + +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.app.ForegroundServiceStartNotAllowedException +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.annotation.Keep +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Builder +import androidx.core.app.NotificationManagerCompat +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.bbflight.background_downloader.BDPlugin.Companion.TAG +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.roundToInt +import kotlin.text.Regex.Companion.escapeReplacement + +/** + * Notification specification + * + * [body] may contain special string {filename] to insert the filename + * and/or special string {progress} to insert progress in % + * + * Actual appearance of notification is dependent on the platform, e.g. + * on iOS {progress} and progressBar are not available and ignored + */ +@OptIn(InternalSerializationApi::class) +@Serializable +class TaskNotification(val title: String, val body: String) { + override fun toString(): String { + return "Notification(title='$title', body='$body')" + } +} + +/** + * Notification configuration object + * + * [running] is the notification used while the task is in progress + * [complete] is the notification used when the task completed + * [error] is the notification used when something went wrong, + * including pause, failed and notFound status + */ +@OptIn(InternalSerializationApi::class) +@Serializable +class NotificationConfig( + val running: TaskNotification?, + val complete: TaskNotification?, + val error: TaskNotification?, + val paused: TaskNotification?, + val canceled: TaskNotification?, + val progressBar: Boolean, + val tapOpensFile: Boolean, + val groupNotificationId: String +) { + override fun toString(): String { + return "NotificationConfig(running=$running, complete=$complete, error=$error, paused=$paused, progressBar=$progressBar, tapOpensFile=$tapOpensFile, groupNotificationId=$groupNotificationId)" + } +} + +/** + * Data associated with a groupNotification + */ +class GroupNotification( + val name: String, + val notificationConfig: NotificationConfig +) { + private var notifications = ConcurrentHashMap() + + /** NotificationId derived from group name */ + val notificationId get() = "groupNotification$name".hashCode() + + /** Total number of notifications in this group */ + val numTotal get() = notifications.size + + /** Progress expressed as [numFinished]/[numTotal], except + * return 2.0 if numTotal is 0, to suggest that progress + * is undetermined + */ + val progress get() = if (numTotal == 0) 2.0 else numFinished.toDouble() / numTotal.toDouble() + + /** Number of "finished" notifications in this group. + * + * A "finished" notification is one that is not .running, + * so includes .complete, .error, .paused + * */ + val numFinished get() = notifications.filter { (_, v) -> v != NotificationType.running }.size + + /** Number of "failed" notifications in this group. + * + * A "failed" notification is one of type .error + * */ + val numFailed get() = notifications.filter { (_, v) -> v == NotificationType.error }.size + + /** True if all tasks finished, regardless of outcome */ + val isFinished get() = numFinished == numTotal + + /** + * Return true if this group has an error + */ + val hasError get() = numFailed > 0 + + /** Returns a Set of running tasks in this groupNotification */ + val runningTasks + get() = notifications.filter { (_, notificationType) -> + notificationType == NotificationType.running + }.keys + + /** Int representing this group's state. If this number + * does not change, the group state did not change. + * + * State is determined by the number of finished notifications + * and the number of total notifications + */ + private val groupState get() = 1000 * numTotal + numFinished + + /** Update a [task] and [notificationType] to this group, + * and return True if this led to change in [groupState] + * + * Must be synchronized to avoid concurrent modification + */ + fun update(task: Task, notificationType: NotificationType): Boolean { + if (notifications[task] == notificationType) { + return false + } + val priorState = groupState + notifications[task] = notificationType + return priorState != groupState + } +} + +@Suppress("EnumEntryName") +enum class NotificationType { running, complete, error, paused, canceled } + +/** + * Receiver for messages from notification, sent via intent + * + * Note the two cancellation actions: one for active tasks (running and managed by a + * [WorkManager] and one for inactive (paused) tasks. Because the latter is not running in a + * [WorkManager] job, cancellation is simpler, but the [NotificationReceiver] must remove the + * notification that asked for cancellation directly from here. If an 'error' notification + * was configured for the task, then it will NOT be shown (as it would when cancelling an active + * task) + */ +@Suppress("ConstPropertyName") +@Keep +class NotificationReceiver : BroadcastReceiver() { + + companion object { + const val actionCancelActive = "com.bbflight.background_downloader.cancelActive" + const val actionCancelInactive = "com.bbflight.background_downloader.cancelInactive" + const val actionPause = "com.bbflight.background_downloader.pause" + const val actionResume = "com.bbflight.background_downloader.resume" + const val actionTap = "com.bbflight.background_downloader.tap" + const val keyBundle = "com.bbflight.background_downloader.bundle" + const val keyTaskId = "com.bbflight.background_downloader.taskId" + const val keyTask = "com.bbflight.background_downloader.task" // as JSON string + const val keyGroupNotificationName = + "com.bbflight.background_downloader.groupNotificationName" + const val keyNotificationConfig = + "com.bbflight.background_downloader.notificationConfig" // as JSON string + const val keyNotificationType = + "com.bbflight.background_downloader.notificationType" // ordinal of enum + const val keyNotificationId = "com.bbflight.background_downloader.notificationId" + } + + override fun onReceive(context: Context, intent: Intent) { + val bundle = intent.getBundleExtra(keyBundle) + val taskId = bundle?.getString(keyTaskId) + if (taskId != null) { + // Run on a background dispatcher via goAsync() to avoid blocking the main thread + val pendingResult = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + handleAction(context, intent, bundle, taskId) + } finally { + pendingResult.finish() + } + } + } else { + // no taskId -> groupNotification, and can only be a cancel action so + // no need to check + val groupNotificationName = bundle?.getString(keyGroupNotificationName) + if (groupNotificationName != null) { + // cancel all tasks associated with this group that have not yet completed + val groupNotification = + NotificationService.groupNotifications[groupNotificationName] + if (groupNotification != null) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + BDPlugin.cancelTasksWithIds( + context, + groupNotification.runningTasks.map { task -> task.taskId }) + } finally { + pendingResult.finish() + } + } + } + } + } + } + + private suspend fun handleAction( + context: Context, + intent: Intent, + bundle: Bundle, + taskId: String + ) { + when (intent.action) { + actionCancelActive -> { + BDPlugin.cancelActiveTaskWithId( + context, taskId, WorkManager.getInstance(context) + ) + } + + actionCancelInactive -> { + val taskJsonString = bundle.getString(keyTask) + if (taskJsonString != null) { + val task = bdJson.decodeFromString(taskJsonString) + BDPlugin.cancelInactiveTask(context, task) + with(NotificationManagerCompat.from(context)) { + cancel(task.taskId.hashCode()) + } + } else { + Log.d(TAG, "task was null") + } + } + + actionPause -> { + BDPlugin.pauseTaskWithId(taskId) + } + + actionResume -> { + val resumeData = BDPlugin.localResumeData[taskId] + if (resumeData != null) { + val notificationConfigJsonString = bundle.getString( + keyNotificationConfig + ) + if (notificationConfigJsonString != null) { + try { + attemptResume( + context, + taskId, + resumeData, + notificationConfigJsonString + ) + } catch (e: Exception) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) { + // If resume button is clicked when app is in background, + // bring app to foreground before attempting resume. + val launchIntent = + context.packageManager.getLaunchIntentForPackage( + context.packageName + )?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (launchIntent != null) { + CoroutineScope(Dispatchers.Main).launch { + try { + // Start the activity, wait, then resume + context.startActivity(launchIntent) + delay(3000) + attemptResume( + context, + taskId, + resumeData, + notificationConfigJsonString + ) + } catch (activityException: ActivityNotFoundException) { + Log.e( + TAG, + "When resuming taskId $taskId, could not find activity to launch for package ${context.packageName}", + activityException + ) + } catch (securityException: SecurityException) { + Log.e( + TAG, + "When resuming taskId $taskId, SecurityException starting activity: ${securityException.message}", + securityException + ) + } catch (e: Exception) { + Log.e( + TAG, + "Exception resuming taskId $taskId: ${e.message}", + e + ) + } + } + } else { + Log.e( + TAG, + "When resuming taskId $taskId, could not get launch intent for package ${context.packageName}" + ) + } + } else { + Log.e( + TAG, + "Error resuming taskId $taskId: ${e.message}", + e + ) + } + } + } else { + BDPlugin.cancelActiveTaskWithId( + context, taskId, WorkManager.getInstance(context) + ) + } + } else { + BDPlugin.cancelActiveTaskWithId( + context, taskId, WorkManager.getInstance(context) + ) + } + } + + else -> {} + } + } + + private suspend fun attemptResume( + context: Context, + taskId: String, + resumeData: ResumeData, + notificationConfigJsonString: String? + ) { + BDPlugin.pausedTaskIds.remove(taskId) // reset flag from an earlier pause + if (!BDPlugin.doEnqueue( + context, + resumeData.task, + notificationConfigJsonString, + resumeData + ) + ) { + Log.i(TAG, "Could not enqueue taskId $taskId to resume") + BDPlugin.holdingQueue?.taskFinished(resumeData.task) + } else { + Log.i(TAG, "Resumed taskId $taskId from notification") + } + } +} + +/** + * Singleton service to manage notifications + */ +@Suppress("ConstPropertyName") +object NotificationService { + var groupNotifications = ConcurrentHashMap() + private val removedNotificationIds: MutableSet = ConcurrentHashMap.newKeySet() + + private const val notificationChannelId = "background_downloader" + + private val mutex = Mutex() + private val queue = Channel(Channel.CONFLATED) + private val pendingNotifications = java.util.ArrayDeque() + private val scope = CoroutineScope(Dispatchers.Default) + private var lastNotificationTime: Long = 0 + private var createdNotificationChannel = false + private const val MIN_NOTIFICATION_INTERVAL_MS = 300L + + /** + * Starts listening to the queue and processes each item + * + * Each item is a NotificationData object that represents a + * new or updated notification + */ + init { + scope.launch { + for (event in queue) processQueue() + } + } + + /** + * Process the queue, one by one, but collapsing progress updates + * for the same notificationId + */ + private suspend fun processQueue() { + while (true) { + val timeSinceLastNotification = System.currentTimeMillis() - lastNotificationTime + if (timeSinceLastNotification < MIN_NOTIFICATION_INTERVAL_MS) { + delay(MIN_NOTIFICATION_INTERVAL_MS - timeSinceLastNotification) + } + val notificationData = mutex.withLock { + if (pendingNotifications.isEmpty()) { + return@withLock null + } + var candidate = pendingNotifications.removeFirst() + // if the candidate is a progress update, check if there are more + // progress updates for the same notificationId in the queue + // and if so, use the last one + if (candidate.notificationType == NotificationType.running) { + val iterator = pendingNotifications.iterator() + while (iterator.hasNext()) { + val next = iterator.next() + if (next.taskWorker.notificationId == candidate.taskWorker.notificationId) { + if (next.notificationType == NotificationType.running) { + // Found a newer progress update, supersede the current candidate + candidate = next + iterator.remove() + } else { + // Found a non-running update (barrier), stop looking for this task + break + } + } + } + } + candidate + } + if (notificationData != null) { + processNotificationData(notificationData) + lastNotificationTime = System.currentTimeMillis() + } else { + break + } + } + } + + /** + * Creates an [UpdateNotificationWorker] to update the notification associated with + * this task + * + * This is used when we do not have access to the [TaskWorker] object, i.e. + * when called from outside an actively running worker. + */ + fun createUpdateNotificationWorker( + context: Context, + taskJson: String, + notificationConfigJson: String?, + taskStatusOrdinal: Int? + ) { + // create dummy TaskWorker + val requestBuilder = OneTimeWorkRequestBuilder() + val dataBuilder = + Data.Builder().putString(TaskWorker.keyTask, taskJson) + .putString(TaskWorker.keyNotificationConfig, notificationConfigJson) + if (taskStatusOrdinal != null) { + dataBuilder.putInt(UpdateNotificationWorker.keyTaskStatusOrdinal, taskStatusOrdinal) + } + requestBuilder.setInputData(dataBuilder.build()) + val workManager = WorkManager.getInstance(context) + workManager.enqueue(requestBuilder.build()) + } + + /** + * Create or update the notification for this [taskWorker], associated with this [taskStatus] + * [progress] and [timeRemaining] + * + * [taskStatus] determines the type of notification, and whether absence of one will + * cancel the notification + * The [progress] field is only relevant for [NotificationType.running]. If progress is + * negative no progress bar will be shown. If progress > 1 an indeterminate progress bar + * will be shown + * [timeRemaining] is only relevant for [NotificationType.running] + * + * If this notification is part of a group, then the notification will be built + * using [updateGroupNotification] and enqueued to avoid overloading the + * notification system. + * + * For normal notification, the caller must throttle the notifications to a + * reasonable rate + */ + @SuppressLint("MissingPermission") + suspend fun updateNotification( + taskWorker: TaskJobContext, + taskStatus: TaskStatus, + progress: Double = 2.0, + timeRemaining: Long = -1000 + ) { + val notificationType = notificationTypeForTaskStatus(taskStatus) + val groupNotificationId = taskWorker.notificationConfig?.groupNotificationId + if (groupNotificationId?.isNotEmpty() == true) { + if (progress == 2.0 && timeRemaining == -1000L) { + // filter out progress updates, as they are irrelevant for group notifications + updateGroupNotification(taskWorker, groupNotificationId, notificationType) + } + return + } + // Ignore 'enqueued' status for normal notifications + if (taskStatus == TaskStatus.enqueued) { + return + } + // regular notification + val notification = when (notificationType) { + NotificationType.running -> taskWorker.notificationConfig?.running + NotificationType.complete -> taskWorker.notificationConfig?.complete + NotificationType.error -> taskWorker.notificationConfig?.error + NotificationType.paused -> taskWorker.notificationConfig?.paused + NotificationType.canceled -> taskWorker.notificationConfig?.canceled + } + if (notification == null) { + addToNotificationQueue(taskWorker) // removes notification + return + } + // need to show a notification + taskWorker.notificationId = taskWorker.task.taskId.hashCode() + if (!createdNotificationChannel) { + createNotificationChannel(taskWorker.appContext) + } + val iconDrawable = when (notificationType) { + NotificationType.running -> if (taskWorker.task.isDownloadTask()) R.drawable.outline_file_download_24 else R.drawable.outline_file_upload_24 + NotificationType.complete -> R.drawable.outline_download_done_24 + NotificationType.error -> R.drawable.outline_error_outline_24 + NotificationType.paused -> R.drawable.outline_pause_24 + NotificationType.canceled -> R.drawable.outline_cancel_24 + } + val builder = Builder( + taskWorker.appContext, notificationChannelId + ).setPriority(NotificationCompat.PRIORITY_LOW).setSmallIcon(iconDrawable) + .setShowWhen(notificationType != NotificationType.running) + .setSilent(appIsInForeground()) + if (notificationType == NotificationType.running) { + // Keep concurrent download notifications in a stable order + builder.setWhen(taskWorker.task.creationTime) + .setSortKey(taskWorker.task.taskId) + .setOnlyAlertOnce(true) + } + // use stored progress if notificationType is .paused + taskWorker.notificationProgress = + if (notificationType == NotificationType.paused) taskWorker.notificationProgress else progress + // title and body interpolation of {filename}, {progress} and {metadata} + val title = replaceTokens( + notification.title, + taskWorker.task, + taskWorker.notificationProgress, + networkSpeed = taskWorker.networkSpeed, + timeRemaining = timeRemaining + ) + if (title.isNotEmpty()) { + builder.setContentTitle(title) + } + val body = replaceTokens( + notification.body, + taskWorker.task, + taskWorker.notificationProgress, + networkSpeed = taskWorker.networkSpeed, + timeRemaining = timeRemaining + ) + if (body.isNotEmpty()) { + builder.setContentText(body) + } + // progress bar + val progressBar = + taskWorker.notificationConfig?.progressBar == true && (notificationType == NotificationType.running || notificationType == NotificationType.paused) + if (progressBar && taskWorker.notificationProgress >= 0) { + if (taskWorker.notificationProgress <= 1) { + builder.setProgress( + 100, (taskWorker.notificationProgress * 100).roundToInt(), false + ) + } else { // > 1 means indeterminate + builder.setProgress(100, 0, true) + } + } + addNotificationActions(taskWorker, notificationType, builder) + addToNotificationQueue(taskWorker, notificationType, builder) + } + + /** + * Register that [item] was enqueued, with [success] or failure + * + * This is only relevant for tasks that are part of a group notification, so that the + * 'numTotal' count is based on enqueued tasks, not on running tasks (which may be limited + * by holdingQueue or OS restrictions). + */ + fun registerEnqueue(item: EnqueueItem, success: Boolean) { + val notificationConfigJsonString = item.notificationConfigJsonString ?: return + val notificationConfig = + bdJson.decodeFromString(notificationConfigJsonString) + val groupNotificationId = notificationConfig.groupNotificationId + if (groupNotificationId.isNotEmpty()) { + // update the notification status for this task (requires a worker because we are + // not within a worker when this function is called) + createUpdateNotificationWorker( + context = item.context, + taskJson = bdJson.encodeToString(item.task), + notificationConfigJson = notificationConfigJsonString, + taskStatusOrdinal = if (success) TaskStatus.enqueued.ordinal else TaskStatus.failed.ordinal + ) + } + } + + /** + * Update notification for this [taskWorker] in group + * [groupNotificationId] and type [notificationType]. + * + * A group notification aggregates the state of all tasks in a group and + * presents a notification based on that value + */ + private suspend fun updateGroupNotification( + taskWorker: TaskJobContext, groupNotificationId: String, notificationType: NotificationType + ) { + val stateChange: Boolean + val groupNotification: GroupNotification + mutex.withLock { + groupNotification = + groupNotifications[groupNotificationId] ?: GroupNotification( + groupNotificationId, taskWorker.notificationConfig!! + ) + stateChange = groupNotification.update(taskWorker.task, notificationType) + groupNotifications[groupNotificationId] = groupNotification + } + if (stateChange || taskWorker.runInForeground) { + // need to update the group notification + taskWorker.notificationId = groupNotification.notificationId + val hasError = groupNotification.hasError + val isFinished = groupNotification.isFinished + val notification = if (isFinished) { + if (hasError) groupNotification.notificationConfig.error else groupNotification.notificationConfig.complete + } else groupNotification.notificationConfig.running + if (notification == null) { + addToNotificationQueue(taskWorker) // removes notification + } else { + // need to show a notification + if (!createdNotificationChannel) { + createNotificationChannel(taskWorker.appContext) + } + val iconDrawable = if (isFinished) { + if (hasError) R.drawable.outline_error_outline_24 else R.drawable.outline_download_done_24 + } else { + if (taskWorker.task.isDownloadTask()) R.drawable.outline_file_download_24 else R.drawable.outline_file_upload_24 + } + val builder = Builder( + taskWorker.appContext, notificationChannelId + ).setPriority(NotificationCompat.PRIORITY_LOW).setSmallIcon(iconDrawable) + .setShowWhen(isFinished) + .setSilent(appIsInForeground()) + // title and body interpolation of tokens + val progress = groupNotification.progress + val title = replaceTokens( + notification.title, + taskWorker.task, + progress, + groupNotification = groupNotification + ) + if (title.isNotEmpty()) { + builder.setContentTitle(title) + } + val body = replaceTokens( + notification.body, + taskWorker.task, + progress, + groupNotification = groupNotification + ) + if (body.isNotEmpty()) { + builder.setContentText(body) + } + // progress bar + val progressBar = + groupNotification.notificationConfig.progressBar && (notification == groupNotification.notificationConfig.running) + if (progressBar && progress >= 0) { + if (progress <= 1) { + builder.setProgress(100, (progress * 100).roundToInt(), false) + } else { // > 1 means indeterminate + builder.setProgress(100, 0, true) + } + } + addGroupNotificationActions( + taskWorker, + notificationType, + groupNotification, + builder + ) + addToNotificationQueue( + taskWorker, + notificationType, + builder + ) // shows notification + } + if (isFinished) { + // remove only if not re-activated within 5 seconds + scope.launch { + delay(5000) + if (groupNotification.isFinished) { + groupNotifications.remove(groupNotificationId) + } + } + } + } + } + + /** + * Add action to notification via buttons or tap + * + * Which button(s) depends on the [notificationType], and the actions require + * access to [taskWorker] and the [builder] + */ + private fun addNotificationActions( + taskWorker: TaskJobContext, notificationType: NotificationType, builder: Builder + ) { + val taskJsonString = bdJson.encodeToString(taskWorker.task) + // add tap action for all notifications + addTapIntent(taskWorker, taskJsonString, notificationType, builder) + // add buttons depending on notificationType + when (notificationType) { + NotificationType.running -> { + // cancel button when running + val cancelOrPauseBundle = Bundle().apply { + putString(NotificationReceiver.keyTaskId, taskWorker.task.taskId) + } + val cancelIntent = + Intent(taskWorker.appContext, NotificationReceiver::class.java).apply { + action = NotificationReceiver.actionCancelActive + putExtra(NotificationReceiver.keyBundle, cancelOrPauseBundle) + } + val cancelPendingIntent: PendingIntent = PendingIntent.getBroadcast( + taskWorker.appContext, + taskWorker.notificationId, + cancelIntent, + PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction( + R.drawable.outline_cancel_24, + BDPlugin.notificationButtonText["Cancel"], + cancelPendingIntent + ) + if (taskWorker.taskCanResume && (taskWorker.notificationConfig?.paused != null)) { + // pause button when running and paused notification configured + val pauseIntent = Intent( + taskWorker.appContext, NotificationReceiver::class.java + ).apply { + action = NotificationReceiver.actionPause + putExtra(NotificationReceiver.keyBundle, cancelOrPauseBundle) + } + val pausePendingIntent: PendingIntent = PendingIntent.getBroadcast( + taskWorker.appContext, + taskWorker.notificationId, + pauseIntent, + PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction( + R.drawable.outline_pause_24, + BDPlugin.notificationButtonText["Pause"], + pausePendingIntent + ) + } + } + + NotificationType.paused -> { + // cancel button + val cancelBundle = Bundle().apply { + putString(NotificationReceiver.keyTaskId, taskWorker.task.taskId) + putString( + NotificationReceiver.keyTask, taskJsonString + ) + } + val cancelIntent = Intent( + taskWorker.appContext, NotificationReceiver::class.java + ).apply { + action = NotificationReceiver.actionCancelInactive + putExtra(NotificationReceiver.keyBundle, cancelBundle) + } + val cancelPendingIntent: PendingIntent = PendingIntent.getBroadcast( + taskWorker.appContext, + taskWorker.notificationId, + cancelIntent, + PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction( + R.drawable.outline_cancel_24, + BDPlugin.notificationButtonText["Cancel"], + cancelPendingIntent + ) + // resume button + val resumeBundle = Bundle().apply { + putString(NotificationReceiver.keyTaskId, taskWorker.task.taskId) + putString( + NotificationReceiver.keyTask, taskJsonString + ) + putString( + NotificationReceiver.keyNotificationConfig, + taskWorker.notificationConfigJsonString + ) + } + val resumeIntent = Intent( + taskWorker.appContext, NotificationReceiver::class.java + ).apply { + action = NotificationReceiver.actionResume + putExtra(NotificationReceiver.keyBundle, resumeBundle) + } + val resumePendingIntent: PendingIntent = PendingIntent.getBroadcast( + taskWorker.appContext, + taskWorker.notificationId, + resumeIntent, + PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction( + R.drawable.outline_play_arrow_24, + BDPlugin.notificationButtonText["Resume"], + resumePendingIntent + ) + } + + NotificationType.complete -> {} + NotificationType.error -> {} + NotificationType.canceled -> {} + } + } + + /** + * Add tap intent to the [builder] for this notification + * + * If [taskJsonString] is not empty, information will be added such that + * the activity can send a "notificationTap" message to Flutter + */ + private fun addTapIntent( + taskWorker: TaskJobContext, + taskJsonString: String, + notificationType: NotificationType, + builder: Builder + ) { + val tapIntent = taskWorker.appContext.packageManager.getLaunchIntentForPackage( + taskWorker.appContext.packageName + ) + if (tapIntent != null) { + tapIntent.apply { + setPackage(null) + action = NotificationReceiver.actionTap + addCategory(Intent.CATEGORY_LAUNCHER) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra(NotificationReceiver.keyTask, taskJsonString) + putExtra( + NotificationReceiver.keyNotificationConfig, + taskWorker.notificationConfigJsonString + ) + putExtra(NotificationReceiver.keyNotificationType, notificationType.ordinal) + putExtra(NotificationReceiver.keyNotificationId, taskWorker.notificationId) + } + val tapPendingIntent: PendingIntent = PendingIntent.getActivity( + taskWorker.appContext, + taskWorker.notificationId, + tapIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + builder.setContentIntent(tapPendingIntent) + } + } + + /** + * Add Cancel action to groupNotification notification + */ + private fun addGroupNotificationActions( + taskWorker: TaskJobContext, + notificationType: NotificationType, + groupNotification: GroupNotification, + builder: Builder + ) { + // add tap action for all notifications + addTapIntent(taskWorker, "", notificationType, builder) + // add cancel button for running notification + if (notificationType == NotificationType.running) { + val cancelBundle = Bundle().apply { + putString(NotificationReceiver.keyGroupNotificationName, groupNotification.name) + } + val cancelIntent = + Intent(taskWorker.appContext, NotificationReceiver::class.java).apply { + action = NotificationReceiver.actionCancelActive + putExtra(NotificationReceiver.keyBundle, cancelBundle) + } + val cancelPendingIntent: PendingIntent = PendingIntent.getBroadcast( + taskWorker.appContext, + groupNotification.notificationId, + cancelIntent, + PendingIntent.FLAG_IMMUTABLE + ) + builder.addAction( + R.drawable.outline_cancel_24, + BDPlugin.notificationButtonText["Cancel"], + cancelPendingIntent + ) + } + } + + /** + * Display the notification presented by the [builder], for this + * [notificationType] + * + * Checks for permissions, and if necessary asks for it + */ + @SuppressLint("MissingPermission") + private suspend fun displayNotification( + taskWorker: TaskJobContext, notificationType: NotificationType, builder: Builder + ) { + with(NotificationManagerCompat.from(taskWorker.appContext)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // On Android 33+, check/ask for permission + val status = PermissionsService.getPermissionStatus( + taskWorker.appContext, + PermissionType.notifications + ) + if (status != PermissionStatus.granted) { + return + } + } + val androidNotification = builder.build() + if (taskWorker.runInForeground) { + if (notificationType == NotificationType.running && taskWorker.isActive) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + taskWorker.setForegroundNotification( + taskWorker.notificationId, + androidNotification, + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + taskWorker.setForegroundNotification( + taskWorker.notificationId, androidNotification, 0 + ) + } + } catch (e: Exception) { + Log.w(TAG, "Could not set foreground notification: ${e.message}") + taskWorker.runInForeground = false + notify(taskWorker.notificationId, androidNotification) + } + } else { + // to prevent the 'not running' notification getting killed as the foreground + // process is terminated, this notification is shown regularly, but with + // a delay + delay(200) + notify(taskWorker.notificationId, androidNotification) + } + } else { + notify(taskWorker.notificationId, androidNotification) + } + } + } + + + /** + * Returns true if the app is in the foreground, i.e. one of its activities is visible. + * + * Notifications are then posted silently (no sound, vibration or heads-up), even if the + * app raised the channel's importance, matching iOS, where notifications are not presented + * as a banner while the app is in the foreground + */ + private fun appIsInForeground(): Boolean { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + return appProcessInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + + /** + * Create the notification channel to use for download notifications + */ + private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager: NotificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + val name = context.getString(R.string.bg_downloader_notification_channel_name) + val descriptionText = context.getString( + R.string.bg_downloader_notification_channel_description + ) + // Keep the importance of an existing channel (e.g. pre-created by the app), + // as re-creating it with a lower importance would downgrade it. + // Name and description are still updated, e.g. after a locale change + val importance = + notificationManager.getNotificationChannel(notificationChannelId)?.importance + ?: NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel( + notificationChannelId, name, importance + ).apply { + description = descriptionText + } + // Register the channel with the system + notificationManager.createNotificationChannel(channel) + } + createdNotificationChannel = true + } + + /** + * Returns the notificationType related to this [status] + */ + private fun notificationTypeForTaskStatus(status: TaskStatus): NotificationType { + return when (status) { + TaskStatus.enqueued, TaskStatus.running -> NotificationType.running + TaskStatus.complete -> NotificationType.complete + TaskStatus.paused -> NotificationType.paused + TaskStatus.canceled -> NotificationType.canceled + else -> NotificationType.error + } + } + + // RegExes for token replacement + private val displayNameRegEx = Regex("""\{displayName\}""", RegexOption.IGNORE_CASE) + private val fileNameRegEx = Regex("""\{filename\}""", RegexOption.IGNORE_CASE) + private val progressRegEx = Regex("""\{progress\}""", RegexOption.IGNORE_CASE) + private val networkSpeedRegEx = Regex("""\{networkSpeed\}""", RegexOption.IGNORE_CASE) + private val timeRemainingRegEx = Regex("""\{timeRemaining\}""", RegexOption.IGNORE_CASE) + private val metaDataRegEx = Regex("""\{metadata\}""", RegexOption.IGNORE_CASE) + private val numFinishedRegEx = Regex("""\{numFinished\}""", RegexOption.IGNORE_CASE) + private val numFailedRegEx = Regex("""\{numFailed\}""", RegexOption.IGNORE_CASE) + private val numTotalRegEx = Regex("""\{numTotal\}""", RegexOption.IGNORE_CASE) + + + /** + * Replace special tokens {displayName}, {filename}, {metadata}, {progress}, {networkSpeed}, + * {timeRemaining}, {numFinished} and {numTotal} with their respective values. + */ + private fun replaceTokens( + input: String, + task: Task, + progress: Double, + networkSpeed: Double = -1.0, + timeRemaining: Long? = null, + groupNotification: GroupNotification? = null + ): String { + val (unpackedFilename, _) = UriUtils.unpack(task.filename) + val safeFilename = unpackedFilename ?: task.filename + + // filename and metadata + val output = displayNameRegEx.replace( + fileNameRegEx.replace( + metaDataRegEx.replace( + input, escapeReplacement(task.metaData) + + ), escapeReplacement(safeFilename) + ), escapeReplacement(task.displayName) + ) + + // progress + val progressString = + if (progress in 0.0..1.0) (progress * 100).roundToInt().toString() + "%" + else "" + val output2 = progressRegEx.replace(output, progressString) + // download speed + val networkSpeedString = + if (networkSpeed <= 0.0 || !networkSpeed.isFinite()) "-- MB/s" else if (networkSpeed > 1) "${networkSpeed.roundToInt()} MB/s" else "${(networkSpeed * 1000).roundToInt()} kB/s" + val output3 = networkSpeedRegEx.replace(output2, networkSpeedString) + // time remaining + var output4 = output3 + if (timeRemaining != null) { + val hours = timeRemaining.div(3600000L) + val minutes = (timeRemaining.mod(3600000L)).div(60000L) + val seconds = (timeRemaining.mod(60000L)).div(1000L) + val timeRemainingString = + if (timeRemaining < 0) "--:--" else if (hours > 0) String.format( + Locale.US, + "%02d:%02d:%02d", hours, minutes, seconds + ) else String.format( + Locale.US, + "%02d:%02d", minutes, seconds + ) + output4 = timeRemainingRegEx.replace(output3, timeRemainingString) + } + return if (groupNotification != null) { + numFailedRegEx.replace( + numFinishedRegEx.replace( + numTotalRegEx.replace(output4, "${groupNotification.numTotal}"), + "${groupNotification.numFinished}" + ), "${groupNotification.numFailed}" + ) + } else { + output4 + } + } + + /** Add display notification request to the queue, and start the + * queue if needed + */ + private suspend fun addToNotificationQueue( + taskWorker: TaskJobContext, + notificationType: NotificationType? = null, + builder: Builder? = null + ) { + mutex.withLock { + if (taskWorker.notificationId != 0) { + if (notificationType != null && builder != null) { + removedNotificationIds.remove(taskWorker.notificationId) + } else { + removedNotificationIds.add(taskWorker.notificationId) + } + } + pendingNotifications.add(NotificationData(taskWorker, notificationType, builder)) + } + queue.send(Unit) + } + + /** + * Cancel a notification that was marked for removal while a UIDT job owned it, + * once [JobService.jobFinished] has detached the notification from the job + */ + fun onUIDTJobFinished(jobContext: UIDTJobService.UIDTJobContext) { + val notificationId = jobContext.notificationId + if (notificationId != 0 && removedNotificationIds.contains(notificationId)) { + scope.launch { + // jobFinished posts to the main looper before detaching in system_server + withContext(Dispatchers.Main) {} + delay(200) + if (removedNotificationIds.remove(notificationId)) { + NotificationManagerCompat.from(jobContext.appContext).cancel(notificationId) + } + } + } + } + + /** + * Process the [notificationData], i.e. send the notification + */ + private suspend fun processNotificationData(notificationData: NotificationData) { + if (notificationData.notificationType != null && notificationData.builder != null) { + displayNotification( + notificationData.taskWorker, + notificationData.notificationType, + notificationData.builder + ) + } else { + // remove the notification + with(NotificationManagerCompat.from(notificationData.taskWorker.appContext)) { + cancel(notificationData.taskWorker.notificationId) + } + } + } + + /** + * Return the [GroupNotification] this [taskId] belongs to, or + * null if not part of a [GroupNotification] + */ + fun groupNotificationWithTaskId(taskId: String): GroupNotification? { + for (group in groupNotifications.values) { + if (group.runningTasks.any { task -> task.taskId == taskId }) { + return group + } + } + return null + } +} + +/** Holds data required to construct a notification */ +data class NotificationData( + val taskWorker: TaskJobContext, + val notificationType: NotificationType?, + val builder: Builder? +) diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/OpenFile.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/OpenFile.kt new file mode 100644 index 0000000..8650b13 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/OpenFile.kt @@ -0,0 +1,39 @@ +package com.bbflight.background_downloader + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import androidx.core.content.FileProvider.getUriForFile +import java.io.File + + +class OpenFileProvider : FileProvider(R.xml.bgd_file_paths) + +/** + * Opens the file at the given path or URI in the default file manager app. + */ +fun doOpenFile(activity: Activity, filePathOrUriString: String, mimeType: String): Boolean { + val uri = Uri.parse(filePathOrUriString) + val intent = Intent(Intent.ACTION_VIEW) + try { + val contentUri = + if (uri.scheme == "content" || uri.scheme == "file") uri + else + getUriForFile( + activity, + activity.packageName + ".com.bbflight.background_downloader.fileprovider", + File(filePathOrUriString) + ) + intent.setDataAndType(contentUri, mimeType) + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + activity.startActivity(intent) + return true + } catch (e: Exception) { + Log.i(BDPlugin.TAG, "Failed to open file $filePathOrUriString: $e") + } + return false +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/ParallelDownloadTaskRunner.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/ParallelDownloadTaskRunner.kt new file mode 100644 index 0000000..4c14bd6 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/ParallelDownloadTaskRunner.kt @@ -0,0 +1,484 @@ +package com.bbflight.background_downloader + +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.HttpURLConnection +import kotlin.math.absoluteValue +import kotlin.math.min +import kotlin.random.Random + +class ParallelDownloadTaskRunner(context: TaskJobContext) : TaskRunner(context) { + + private var parallelDownloadContentLength = -1L + private var chunks: List = mutableListOf() + private var chunksJsonString = "" + private var parallelTaskStatusUpdateCompleter = CompletableDeferred() + private var lastTaskStatus = TaskStatus.enqueued + + override fun determineIfResume(): Boolean { + chunksJsonString = context.getInputString(keyResumeDataData) ?: "" + return chunksJsonString.isNotEmpty() + } + + override suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus { + // Register this runner with BDPlugin so it can receive chunk updates + BDPlugin.parallelDownloadTaskWorkers[task.taskId] = this + + canRunInForeground = true + runInForeground = notificationConfig?.running != null + connection.requestMethod = "HEAD" + val result = super.connectAndProcess(connection) + BDPlugin.parallelDownloadTaskWorkers.remove(task.taskId) + return result + } + + override suspend fun process( + connection: HttpURLConnection, + ): TaskStatus { + return withContext(Dispatchers.Default) { + var enqueueJob: Job? = null + var testerJob: Job? = null + try { + enqueueJob = launch { + if (!isResume) { + // start the download by creating [Chunk]s and enqueuing chunk tasks + if (connection.responseCode in listOf(200, 201, 202, 203, 204, 205, 206)) { + // if no filename is set, get from headers or url, update task + if (!task.hasFilename()) { + task = task.withSuggestedFilenameFromResponseHeaders( + context.appContext, + connection.headerFields, + unique = true + ) + Log.d( + TAG, + "Suggested filename for taskId ${task.taskId}: ${task.filename}" + ) + } + extractResponseHeaders(connection.headerFields) + responseStatusCode = connection.responseCode + extractContentType(connection.headerFields) + chunks = createChunks(task, connection.headerFields) + for (chunk in chunks) { + // Ask Dart side to enqueue the child task. Updates related to the child + // will be sent to this (parent) task (the child's metaData is the parent taskId). + postOnBackgroundChannel( + "enqueueChild", + task, + bdJson.encodeToString(chunk.task), + onFail = + { + // failed to enqueue child + cancelAllChunkTasks() + Log.i( + TAG, + "Failed to enqueue chunk task with id ${chunk.task.taskId}" + ) + taskException = TaskException( + ExceptionType.general, + description = "Failed to enqueue chunk task with id ${chunk.task.taskId}" + ) + parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed) + }) + } + } else { + // HTTP response code not OK + Log.i( + TAG, + "Response code ${connection.responseCode} for taskId ${task.taskId}" + ) + val errorContent = responseErrorContent(connection) + taskException = TaskException( + ExceptionType.httpResponse, + httpResponseCode = connection.responseCode, + description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage + ) + if (connection.responseCode == 404) { + responseBody = errorContent + parallelTaskStatusUpdateCompleter.complete(TaskStatus.notFound) + } else { + parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed) + } + } + } else { + // resume: reconstruct [chunks] and wait for all chunk tasks to complete. + // The Dart side will resume each chunk task, so we just wait for the + // completer to complete + chunks = bdJson.decodeFromString(chunksJsonString) + parallelDownloadContentLength = chunks.fold(0L) { acc, chunk -> + acc + chunk.toByte - chunk.fromByte + 1 + } + } + } + testerJob = launch { + while (isActive) { + // check if task is stopped (canceled), paused or timed out + if (context.isTaskStopped) { + withContext(NonCancellable) { + cancelAllChunkTasks() + parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed) + } + break + } + // 'pause' is signalled by adding the taskId to a static list + if (BDPlugin.pausedTaskIds.remove(task.taskId)) { + pauseAllChunkTasks() + postOnBackgroundChannel( + "resumeData", + task, + bdJson.encodeToString>(chunks) + ) + parallelTaskStatusUpdateCompleter.complete(TaskStatus.paused) + break + } + delay(200) + } + } + // wait for all chunks to finish + return@withContext parallelTaskStatusUpdateCompleter.await() + } catch (e: Exception) { + + Log.i(TAG, "Exception for taskId ${task.taskId}: $e") + taskException = TaskException( + ExceptionType.general, + description = "Exception for taskId ${task.taskId}: $e" + ) + return@withContext TaskStatus.failed + } finally { + enqueueJob?.cancelAndJoin() + testerJob?.cancelAndJoin() + } + } + } + + /** + * Process incoming [status] update for a chunk with [chunkTaskId] + * + * If status is failure, may include [taskException] and [responseBody] + */ + suspend fun chunkStatusUpdate( + chunkTaskId: String, + status: TaskStatus, + taskException: TaskException?, + responseBody: String? + ) { + val chunk = chunks.firstOrNull { it.task.taskId == chunkTaskId } + ?: return // chunk is not part of this parent task + val chunkTask = chunk.task + // first check for fail -> retry + if (status == TaskStatus.failed && chunkTask.retriesRemaining > 0) { + chunkTask.retriesRemaining-- + val waitTimeSeconds = 2 shl (min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8)) + Log.i( + TAG, + "Chunk task with taskId ${chunkTask.taskId} failed, waiting $waitTimeSeconds seconds before retrying. ${chunkTask.retriesRemaining} retries remaining" + ) + delay(waitTimeSeconds * 1000L) + postOnBackgroundChannel( + "enqueueChild", + task, + bdJson.encodeToString(chunk.task), + onFail = { + chunkStatusUpdate(chunkTaskId, TaskStatus.failed, taskException, responseBody) + }) + } else { + // no retry + val newStatusUpdate = updateChunkStatus(chunk, status) + if (newStatusUpdate != null) { + when (newStatusUpdate) { + TaskStatus.complete -> { + val stitchResult = stitchChunks() + parallelTaskStatusUpdateCompleter.complete(stitchResult) + } + + TaskStatus.failed -> { + this.taskException = taskException + cancelAllChunkTasks() + parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed) + } + + TaskStatus.notFound -> { + this.taskException = taskException + this.responseBody = responseBody + cancelAllChunkTasks() + parallelTaskStatusUpdateCompleter.complete(TaskStatus.notFound) + } + + else -> { + // ignore all other status updates + } + } + } + } + } + + + /** + * Process incoming [progress] update for a chunk with [chunkTaskId]. + * + * Recalculates overall task progress (based on the average of the chunk + * task progress) and sends an update to the Dart side and updates the + * notification at the appropriate interval + */ + suspend fun chunkProgressUpdate(chunkTaskId: String, progress: Double) { + val chunk = chunks.firstOrNull { it.task.taskId == chunkTaskId } + ?: return // chunk is not part of this parent task + if (progress > 0 && progress < 1) { + val parentProgress = updateChunkProgress(chunk, progress) + if (shouldSendProgressUpdate(parentProgress, System.currentTimeMillis())) { + updateProgressAndNotify(parentProgress, parallelDownloadContentLength, task) + } + } + } + + /** + * Update the status for this chunk, and return the status for the parent task + * as derived from the sum of the child tasks, or null if undefined + * + * The updates are received from the NativeDownloader, which intercepts + * status updates for the chunkGroup + */ + private fun updateChunkStatus(chunk: Chunk, status: TaskStatus): TaskStatus? { + chunk.status = status + val parentStatus = parentTaskStatus() + if (parentStatus != null && parentStatus != lastTaskStatus) { + lastTaskStatus = parentStatus + return parentStatus + } + return null + } + + /** + * Returns the [TaskStatus] for the parent of this chunk, as derived from + * the 'sum' of the child tasks, or null if undetermined + * + * The updates are received from the NativeDownloader, which intercepts + * status updates for the chunkGroup + */ + private fun parentTaskStatus(): TaskStatus? { + val failed = chunks.firstOrNull { it.status == TaskStatus.failed } + if (failed != null) { + return TaskStatus.failed + } + + val notFound = chunks.firstOrNull { it.status == TaskStatus.notFound } + if (notFound != null) { + return TaskStatus.notFound + } + + val allComplete = chunks.all { it.status == TaskStatus.complete } + if (allComplete) { + return TaskStatus.complete + } + return null + } + + + /** + * Updates the chunk's progress and returns the average progress + * + * Returns the [progress] for the parent of this chunk, as derived from + * its children by averaging + */ + private fun updateChunkProgress(chunk: Chunk, progress: Double): Double { + chunk.progress = progress + return chunks.fold( + 0.0 + ) { previousValue, c -> + previousValue + c.progress + } / chunks.size + } + + /** + * Cancel the tasks associated with each chunk + * + * Accomplished by sending list of taskIds to cancel to the NativeDownloader + */ + suspend fun cancelAllChunkTasks() { + postOnBackgroundChannel( + "cancelTasksWithId", + task, + bdJson.encodeToString>(chunks.map { it.task.taskId }) + ) + } + + /** + * Pause the tasks associated with each chunk + * + * Accomplished by sending a json decoded list of tasks to cancel + * to the NativeDownloader + */ + private suspend fun pauseAllChunkTasks() { + postOnBackgroundChannel("pauseTasks", task, bdJson.encodeToString>(chunks.map { it.task })) + } + + /** + * Stitch all chunks together into one file + */ + private suspend fun stitchChunks(): TaskStatus { + return withContext(Dispatchers.IO) { + try { + val dataBuffer = ByteArray(bufferSize) + var numBytes: Int + val outFile = File(task.filePath(context.appContext)) + if (outFile.exists()) { + outFile.delete() + } + FileOutputStream(outFile).use { outStream -> + for (chunk in chunks.sortedBy { it.fromByte }) { + val inFile = File(chunk.task.filePath(context.appContext)) + if (!inFile.exists()) { + throw java.io.FileNotFoundException("Missing chunk file: ${inFile.path}") + } + FileInputStream(inFile).use { inStream -> + while (inStream.read( + dataBuffer, 0, + bufferSize + ) + .also { numBytes = it } != -1 + ) { + outStream.write(dataBuffer, 0, numBytes) + } + } + } + outStream.flush() + } + } catch (e: Exception) { + Log.i(TAG, "Error stitching chunks: $e\n${e.stackTraceToString()}") + taskException = TaskException( + ExceptionType.fileSystem, + description = "Error stitching chunks: $e" + ) + return@withContext TaskStatus.failed + } finally { + for (chunk in chunks) { + try { + val file = File(chunk.task.filePath(context.appContext)) + file.delete() + } catch (_: Exception) { + // ignore + } + } + } + return@withContext TaskStatus.complete + } + } + + + /** + * Returns a list of chunk information for this task, and sets + * [parallelDownloadContentLength] to the total length of the download + * + * Throws a IllegalStateException if any information is missing, which should lead + * to a failure of the ParallelDownloadTask + */ + private fun createChunks( + task: Task, + headers: MutableMap> + ): List { + val numChunks = task.urls.size * task.chunks + try { + val contentLength = getContentLength(headers, task) + if (contentLength <= 0) { + throw IllegalStateException("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header") + } + parallelDownloadContentLength = contentLength + context.updateEstimatedNetworkBytes(contentLength, 0L) + try { + headers.entries + .first { (it.key == "accept-ranges" || it.key == "Accept-Ranges") && it.value.first() == "bytes" } + } catch (_: NoSuchElementException) { + throw IllegalStateException("Server does not accept ranges - cannot chunk download") + } + val chunkSize = (contentLength / numChunks) + 1 + return (0 until numChunks).map { i -> + Chunk( + parentTask = task, + url = task.urls[i % task.urls.size], + filename = "com.bbflight.background_downloader.${Random.nextInt().absoluteValue}", + from = i * chunkSize, + to = min(i * chunkSize + chunkSize - 1, contentLength - 1) + ) + } + } catch (_: NoSuchElementException) { + throw IllegalStateException("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header") + } + } +} + +@Suppress("unused") +@OptIn(InternalSerializationApi::class) +@Serializable +class Chunk private constructor( + private val parentTaskId: String, + private val url: String, + private val filename: String, + val task: Task, + val fromByte: Long, + val toByte: Long, + var status: TaskStatus = TaskStatus.enqueued, + var progress: Double = 0.0 +) { + companion object { + /** + * Returns [Updates] that is based on the [parentTask] + */ + fun updatesBasedOnParent(parentTask: Task): Updates { + return when (parentTask.updates) { + Updates.none, Updates.status -> Updates.status + Updates.progress, Updates.statusAndProgress -> Updates.statusAndProgress + } + } + } + + /** + * Constructor that also creates the [Task] associated with this [Chunk] + */ + constructor(parentTask: Task, url: String, filename: String, from: Long, to: Long) : this( + parentTaskId = parentTask.taskId, + url, + filename, + task = Task( + url = url, + filename = filename, + headers = parentTask.headers + mapOf("Range" to "bytes=$from-$to"), + httpRequestMethod = "GET", + chunks = 1, + post = null, + fileField = "", + mimeType = "", + baseDirectory = BaseDirectory.applicationDocuments, + group = TaskRunner.chunkGroup, + updates = updatesBasedOnParent(parentTask), + retries = parentTask.retries, + retriesRemaining = parentTask.retries, + requiresWiFi = parentTask.requiresWiFi, + allowPause = parentTask.allowPause, + priority = parentTask.priority, + metaData = bdJson.encodeToString(ChunkTaskMetaData(parentTask.taskId, from, to)), + taskType = "DownloadTask" + ), + from, + to + ) +} + +@Serializable +@OptIn(InternalSerializationApi::class) +/// Holder for metaData for [Task] related to a [Chunk] +data class ChunkTaskMetaData(val parentTaskId: String, val from: Long, val to: Long) diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/ParallelDownloadTaskWorker.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/ParallelDownloadTaskWorker.kt new file mode 100644 index 0000000..29ba1af --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/ParallelDownloadTaskWorker.kt @@ -0,0 +1,12 @@ +package com.bbflight.background_downloader + +import android.content.Context +import androidx.work.WorkerParameters + +class ParallelDownloadTaskWorker(context: Context, params: WorkerParameters) : + TaskWorker(context, params) { + + override fun createRunner(): TaskRunner { + return ParallelDownloadTaskRunner(this) + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/PermissionsService.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/PermissionsService.kt new file mode 100644 index 0000000..64f1453 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/PermissionsService.kt @@ -0,0 +1,201 @@ +@file:Suppress("EnumEntryName") + +package com.bbflight.background_downloader + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat + +enum class PermissionType { + notifications, androidSharedStorage, iosAddToPhotoLibrary, iosChangePhotoLibrary +} + +@Suppress("unused") +enum class PermissionStatus { + undetermined, denied, granted, partial, requestError +} + +class PermissionsService { + companion object { + private const val baseRequestCode = 373900 + + /** + * Requests [PermissionStatus] for permission of [permissionType] + * + * Requests [PermissionStatus.granted] for unknown permissions + */ + fun getPermissionStatus( + context: Context, + permissionType: PermissionType + ): PermissionStatus { + when (permissionType) { + PermissionType.notifications -> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // On Android 33+, check/ask for permission + val auth = ActivityCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) + if (auth == PackageManager.PERMISSION_GRANTED) + PermissionStatus.granted + else + PermissionStatus.denied + } else { + PermissionStatus.granted + } + } + + PermissionType.androidSharedStorage -> { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + ) { + // On Android before 29, check/ask for permission + val auth = ActivityCompat.checkSelfPermission( + context, Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + if (auth == PackageManager.PERMISSION_GRANTED) + PermissionStatus.granted + else + PermissionStatus.denied + } else { + PermissionStatus.granted + } + } + + else -> { + return PermissionStatus.granted + } + } + } + + /** + * Requests permission for [permissionType] adn returns a Boolean + * If true a request has been made that the flutter side should wait for + * If false, no request has been made -> check permission status separately + * + * Does not wait for result. Once result is received via the + * [onRequestPermissionsResult], a message is sent back to the + * Flutter side with the permission result + */ + fun requestPermission(plugin: BDPlugin, permissionType: PermissionType): Boolean { + val requestCode = baseRequestCode + permissionType.ordinal + when (permissionType) { + PermissionType.notifications -> { + // On Android 33+, check/ask for permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (plugin.activity == null) { + return false + } + ActivityCompat.requestPermissions( + plugin.activity!!, arrayOf(Manifest.permission.POST_NOTIFICATIONS), + requestCode + ) + return true + } else { + return false + } + } + + PermissionType.androidSharedStorage -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + // On Android before 29, check/ask for permission + if (plugin.activity == null) { + return false + } + ActivityCompat.requestPermissions( + plugin.activity!!, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + requestCode + ) + // completer will be completed via [onRequestPermissionsResult] + return true + + } else { + return false + } + } + + else -> { + return false + } + } + } + + /** + * Returns true if Android shouldShowRequestPermissionRationale returns true for this + * [permissionType]. Returns false for unrecognized [permissionType] + */ + fun shouldShowRequestPermissionRationale( + plugin: BDPlugin, + permissionType: PermissionType + ): Boolean { + val activity = plugin.activity + return if (activity != null) { + when (permissionType) { + PermissionType.notifications -> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.POST_NOTIFICATIONS + ) + } else { + false + } + } + + PermissionType.androidSharedStorage -> { + return ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + } + + else -> return false + } + } else { + false + } + } + + /// Processes the onRequestPermissionsResult received by the Activity + fun onRequestPermissionsResult( + plugin: BDPlugin, + requestCode: Int, grantResults: IntArray + ): Boolean { + val granted = + (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) + return when (requestCode) { + baseRequestCode + PermissionType.notifications.ordinal, + baseRequestCode + PermissionType.androidSharedStorage.ordinal -> { + sendPermissionResult(plugin, granted) + true + } + + else -> { + false + } + } + } + + /** + * Send the [granted] result via the background channel to Flutter + * + * Only one request can be 'in flight' at any time + */ + private fun sendPermissionResult( + plugin: BDPlugin, + granted: Boolean + ) { + // send with dummy task ("") + val bgChannel = BDPlugin.backgroundChannel(plugin) + bgChannel?.invokeMethod( + "permissionRequestResult", + listOf( + "", + if (granted) PermissionStatus.granted.ordinal else PermissionStatus.denied.ordinal + ) + ) + + } + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/QueueService.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/QueueService.kt new file mode 100644 index 0000000..e6d24d4 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/QueueService.kt @@ -0,0 +1,124 @@ +package com.bbflight.background_downloader + +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Queue service that executes things on a queue, to ensure ordered execution + * and potentially manage delay + */ +@Suppress("ConstPropertyName") +object QueueService { + private val scope = CoroutineScope(Dispatchers.Default) + + private val taskIdDeletionQueue = Channel(capacity = Channel.UNLIMITED) + private var lastTaskIdAdditionTime: Long = 0 + private const val minTaskIdDeletionDelay: Long = 2000L //ms + + private val backgroundPostQueue = Channel(capacity = Channel.UNLIMITED) + + + /** + * Starts listening to the queues and processes each item + * + * taskIdDeletionQueue: + * Each item is a taskId and it will be removed from the BDPlugin.pluginByTaskId, + * BDPlugin.bgChannelByTaskId, BDPlugin.localResumeData + * and BDPlugin.notificationConfigs maps + * + * backgroundPostQueue: + * Each item is a [BackgroundPost] that will be posted on the UI thread, and its + * success completer will complete with true if successfully posted + */ + init { + scope.launch { + for (taskId in taskIdDeletionQueue) { + val now = System.currentTimeMillis() + val elapsed = now - lastTaskIdAdditionTime + if (elapsed < minTaskIdDeletionDelay) { + delay(minTaskIdDeletionDelay - elapsed) + } + BDPlugin.flutterEngineByTaskId.remove(taskId) + BDPlugin.bgChannelByTaskId.remove(taskId) + BDPlugin.localResumeData.remove(taskId) + BDPlugin.notificationConfigJsonStrings.remove(taskId) + } + } + scope.launch { + for (bgPost in backgroundPostQueue) { + val success = CompletableDeferred() + launch(Dispatchers.Main) { + try { + val argList = mutableListOf( + TaskWorker.taskToJsonString(bgPost.task) + ) + if (bgPost.arg is ArrayList<*>) { + argList.addAll(bgPost.arg) + } else { + argList.add(bgPost.arg) + } + val bgChannel = BDPlugin.backgroundChannel(taskId = bgPost.task.taskId) + if (bgChannel != null) { + bgChannel.invokeMethod( + bgPost.method, argList, FlutterBooleanResultHandler(success) + ) + } else { + Log.i( + TaskRunner.TAG, + "Could not post ${bgPost.method} to background channel" + ) + success.complete(false) + } + } catch (e: Exception) { + Log.w( + TaskRunner.TAG, + "Exception trying to post ${bgPost.method} to background channel: ${e.message}" + ) + if (!success.isCompleted) { + success.complete(false) + } + } + val posted = !BDPlugin.forceFailPostOnBackgroundChannel && success.await() + if (posted) { + bgPost.onSuccess?.invoke() + } else { + bgPost.onFail?.invoke() + } + } + } + } + } + + + /** + * Remove this [taskId] from the [BDPlugin.bgChannelByTaskId] map and the + * [BDPlugin.localResumeData] map + */ + suspend fun cleanupTaskId(taskId: String) { + lastTaskIdAdditionTime = System.currentTimeMillis() + taskIdDeletionQueue.send(taskId) + } + + /** + * Post this [BackgroundPost] on the background channel + */ + suspend fun postOnBackgroundChannel(bgPost: BackgroundPost) { + backgroundPostQueue.send(bgPost) + } +} + +/** + * BackgroundPost to be sent via backgroundChannel to Flutter, used in [QueueService] + */ +data class BackgroundPost( + val task: Task, + val method: String, + val arg: Any, + val onFail: (suspend () -> Unit)? = null, + val onSuccess: (suspend () -> Unit)? = null +) diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/SharedStorage.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/SharedStorage.kt new file mode 100644 index 0000000..4c7a75d --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/SharedStorage.kt @@ -0,0 +1,325 @@ +package com.bbflight.background_downloader + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.annotation.RequiresApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.OutputStream + +/// Scoped Storage destinations for Android +@Suppress("EnumEntryName") +enum class SharedStorage { downloads, images, video, audio, files, external } + +val leadingPathSeparatorRegEx = Regex("""^/+""") +val trailingPathSeparatorRegEx = Regex("""/$""") + +/** + * Moves the file from filePath to the shared storage destination and returns the path to + * that file if successful, or null if not. If [asUriString] is true, the URI will be returned + * instead of the file path + * + * If successful, the original file will have been deleted + */ +suspend fun moveToSharedStorage( + context: Context, + filePathOrUriString: String, + destination: SharedStorage, + directory: String, + mimeType: String?, + asUriString: Boolean +): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return moveToPublicDirectory(filePathOrUriString, destination, directory) + } + val file = if (filePathOrUriString.startsWith("file://")) { + File(Uri.parse(filePathOrUriString).path!!) + } else File(filePathOrUriString) + if (!file.exists()) { + Log.i( + BDPlugin.TAG, + "File $filePathOrUriString does not exist -> cannot move to shared storage" + ) + return null + } + val cleanDirectory = + trailingPathSeparatorRegEx.replace(leadingPathSeparatorRegEx.replace(directory, ""), "") + // Set up the content values for the new file + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType ?: getMimeType(file.name)) + put(MediaStore.MediaColumns.RELATIVE_PATH, getRelativePath(destination, cleanDirectory)) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + var success = false + // Insert the new file into the MediaStore + val resolver = context.contentResolver + val uri = try { + resolver.insert(getMediaStoreUri(destination), contentValues) + } catch (e: Exception) { + Log.i(BDPlugin.TAG, "Cannot insert $filePathOrUriString in MediaStore: $e") + return null + } + if (uri != null) { + try { + // Get an OutputStream and write the contents of the file + val os: OutputStream? = uri.let { resolver.openOutputStream(it) } + if (os != null) { + os.use { output -> + FileInputStream(file).use { input -> + input.copyTo(output) + } + } + success = true + } + } catch (e: Exception) { + Log.i( + BDPlugin.TAG, + "Error moving file $filePathOrUriString to shared storage: $e" + ) + } finally { + if (success) { + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + try { + resolver.update(uri, contentValues, null, null) + } catch (e: Exception) { + Log.i( + BDPlugin.TAG, + "Failed to reset IS_PENDING: $e" + ) + } + } else { + try { + resolver.delete(uri, null, null) + } catch (e: Exception) { + Log.i( + BDPlugin.TAG, + "Failed to delete incomplete MediaStore entry: $e" + ) + } + } + } + } + // If the file was moved successfully, remove the original + if (success) { + file.delete() + return if (asUriString) uri!!.toString() else pathFromUri(context, uri!!) + } else { + return null + } +} + +/** + * Moves the file from filePath to the shared storage destination and returns the path to + * that file if successful, or null if not + * + * If successful, the original file will have been deleted + * + * This implementation is for Android versions before Q and requires app permissions + * READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE + */ +private fun moveToPublicDirectory( + filePath: String, destination: SharedStorage, directory: String +): String? { + try { + val file = File(filePath) + if (!file.exists()) { + Log.i( + BDPlugin.TAG, + "File $filePath does not exist -> cannot move to public directory" + ) + return null + } + + val destinationMediaPath = getMediaStorePathBelowQ(destination) + val rootDir = Environment.getExternalStoragePublicDirectory(destinationMediaPath) + val destDir = File(rootDir, directory) + if (!destDir.exists()) { + destDir.mkdirs() + } + // try to get a new file name if already exist + val maxCheck = 100 + var currentCheck = 1 + var destinationFile = File(destDir, file.name) + val fileName = file.nameWithoutExtension + val fileHasExtension = file.name.contains(".") + val fileExtension = if (fileHasExtension) ".${file.extension}" else "" + while (destinationFile.exists() && currentCheck < maxCheck) { + destinationFile = File(destDir, "${fileName}_$currentCheck$fileExtension") + currentCheck++ + } + if (destinationFile.exists()) throw Exception("Destination file exist!") + + // copy file + destinationFile.outputStream().use { output -> + FileInputStream(file).use { input -> + input.copyTo(output) + } + } + file.delete() + return destinationFile.absolutePath + } catch (e: Exception) { + Log.i( + BDPlugin.TAG, + "Unable to move file $filePath to public directory: $e" + ) + return null + } +} + +/** + * Returns the path to the file in shared storage, or null + * + * The [filePathOrUriString] can represent a file:// Uri but not a content:// Uri + * + * If [asUriString] is true, returns the URI as a String + */ +fun pathInSharedStorage( + context: Context, + filePathOrUriString: String, + destination: SharedStorage, + directory: String, + asUriString: Boolean +): String? { + // Check if filePath is a file:// URI + val fileUri = Uri.parse(filePathOrUriString) + val fileName = if (fileUri.scheme == "file") { + File(fileUri.path!!).name + } else { + File(filePathOrUriString).name + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + val destinationMediaPath = getMediaStorePathBelowQ(destination) + val rootDir = Environment.getExternalStoragePublicDirectory(destinationMediaPath) + val destDir = File(rootDir, directory) + val destinationFile = File(destDir, fileName) + return destinationFile.path + } + // Version above Q uses MediaStore + context.contentResolver.query( + getMediaStoreUri(destination), + arrayOf( + MediaStore.Images.Media.DATA, + MediaStore.MediaColumns._ID + ), // same for all collections AFAIK + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(fileName), + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + return if (asUriString) { + Uri.withAppendedPath( + getMediaStoreUri(destination), + cursor.getLong(1).toString() + ).toString() + } else cursor.getString(0) + } + } + return null +} + +/** + * Returns path to media store [destination] for Android versions at or above Q + */ +@RequiresApi(Build.VERSION_CODES.Q) +fun getMediaStoreUri(destination: SharedStorage): Uri { + return when (destination) { + SharedStorage.files -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + SharedStorage.downloads -> MediaStore.Downloads.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + + SharedStorage.images -> MediaStore.Images.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + + SharedStorage.video -> MediaStore.Video.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + + SharedStorage.audio -> MediaStore.Audio.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + + SharedStorage.external -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + } +} + +/** + * Returns path to media store [destination] for Android versions below Q + */ +private fun getMediaStorePathBelowQ(destination: SharedStorage): String { + return when (destination) { + SharedStorage.files -> Environment.DIRECTORY_DOCUMENTS + SharedStorage.downloads -> Environment.DIRECTORY_DOWNLOADS + SharedStorage.images -> Environment.DIRECTORY_PICTURES + SharedStorage.video -> Environment.DIRECTORY_MOVIES + SharedStorage.audio -> Environment.DIRECTORY_MUSIC + SharedStorage.external -> "" + } +} + +/** + * Returns file path to ScopedStorage [destination] and subdirectory [directory] + */ +private fun getRelativePath(destination: SharedStorage, directory: String): String { + val sharedStorageDirectory = when (destination) { + SharedStorage.files -> Environment.DIRECTORY_DOCUMENTS + SharedStorage.downloads -> Environment.DIRECTORY_DOWNLOADS + SharedStorage.images -> Environment.DIRECTORY_PICTURES + SharedStorage.video -> Environment.DIRECTORY_MOVIES + SharedStorage.audio -> Environment.DIRECTORY_MUSIC + SharedStorage.external -> "" + } + return if (directory.isEmpty()) sharedStorageDirectory else "$sharedStorageDirectory/$directory" +} + + +/** + * Return Mime type for this [fileNameOrUriString], as a String + * + * Defaults to application/octet-stream + */ +fun getMimeType(fileNameOrUriString: String): String { + val extension = fileNameOrUriString.substringAfterLast(".", "") + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + ?: "application/octet-stream" +} + +/** + * Returns the file path related to this MediaStore [uri], or null + */ +suspend fun pathFromUri(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) { + val projection = arrayOf(MediaStore.Images.Media.DATA) + val cursor: Cursor? = context.contentResolver.query(uri, projection, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + return@withContext it.getString(columnIndex) + } + } + return@withContext null +} + + +//suspend fun pathFromUri(context: Context, uri: Uri): String? { +// val proj = arrayOf(MediaStore.Images.Media.DATA) +// val loader = CursorLoader(context, uri, proj, null, null, null) +// val cursor = withContext(Dispatchers.IO) { loader.loadInBackground() } +// ?: return null +// val columnIndex: Int = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) +// cursor.moveToFirst() +// val result = cursor.getString(columnIndex) +// cursor.close() +// return result +//} \ No newline at end of file diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/TaskRunner.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/TaskRunner.kt new file mode 100644 index 0000000..681df6c --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/TaskRunner.kt @@ -0,0 +1,1206 @@ +package com.bbflight.background_downloader + +import android.app.job.JobParameters +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.util.Log +import androidx.preference.PreferenceManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import java.io.DataOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.Double.min as doubleMin +import java.lang.System.currentTimeMillis +import java.net.HttpURLConnection +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.URL +import java.net.URLDecoder +import java.net.MalformedURLException +import java.util.UUID +import kotlin.concurrent.read +import kotlin.concurrent.write +import androidx.core.content.edit +import androidx.work.WorkInfo +import androidx.work.WorkManager + + +/** + * Interface that abstracts the differences between a WorkManager worker and a JobService job + */ +interface TaskJobContext { + val appContext: Context + + // Mutable properties needed for NotificationService and Task execution + var task: Task + var notificationConfig: NotificationConfig? + var notificationId: Int + var notificationProgress: Double + var networkSpeed: Double + var taskCanResume: Boolean + var notificationConfigJsonString: String? + val isTaskStopped: Boolean + val runId: String? // the WorkManager request id, or null where runs can't be told apart + + // Foreground control + var runInForeground: Boolean + val isActive: Boolean // maps to !isStopped or Service state + + /** + * Set the task to run in foreground with the given notification + */ + suspend fun setForegroundNotification(notificationId: Int, notification: android.app.Notification, notificationType: Int) + + /** + * Get a Long value from the input data + */ + fun getInputLong(key: String, defaultValue: Long): Long + + /** + * Get a String value from the input data + */ + fun getInputString(key: String): String? + + /** + * Update the notification for this task. + * Implementations should call NotificationService.updateNotification(this, ...). + */ + suspend fun updateNotification( + task: Task, + status: TaskStatus, + progress: Double = 2.0, + timeRemaining: Long = -1000 + ) + + /** + * Update the estimated network bytes for this task (Android 14+ UIDT only) + */ + fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) +} + +/*** + * The runner to execute one task. + * + * It is called from [TaskWorker] (WorkManager) or [UIDTJobService] (JobScheduler), which + * provide the [TaskJobContext] + */ +open class TaskRunner( + val context: TaskJobContext +) { + + companion object { + const val TAG = "TaskRunner" + const val chunkGroup = "chunk" + const val keyTask = "Task" + const val keyNotificationConfig = "notificationConfig" + const val keyResumeDataData = "tempFilename" + const val keyStartByte = "startByte" + const val keyETag = "eTag" + const val bufferSize = 2 shl 12 + + const val taskTimeoutMillis = 9 * 60 * 1000L // 9 minutes + const val expeditedTaskTimeoutMillis = 2 * 60 * 1000L // 2 minutes + + /** Converts [Task] to JSON string representation */ + fun taskToJsonString(task: Task): String { + return bdJson.encodeToString(task) + } + + /** + * Post method message on backgroundChannel with arguments + * + * If the post is not successful, execute the [onFail] block, if given + * + * [arg] can be single variable or a MutableList + */ + suspend fun postOnBackgroundChannel( + method: String, + task: Task, + arg: Any, + onFail: (suspend () -> Unit)? = null, + onSuccess: (suspend () -> Unit)? = null + ) { + val bgPost = BackgroundPost(task, method, arg, onFail, onSuccess) + QueueService.postOnBackgroundChannel(bgPost) + } + + /** + * Processes a change in status for the task + * + * Sends status update via the background channel to Flutter, if requested + * If the task is finished, processes a final progressUpdate update and removes + * task from persistent storage. + * + * Optional [taskException] for status .failed + * */ + suspend fun processStatusUpdate( + task: Task, + status: TaskStatus, + prefs: SharedPreferences, + taskException: TaskException? = null, + responseBody: String? = null, + responseHeaders: Map? = null, + responseStatusCode: Int? = null, + mimeType: String? = null, + charSet: String? = null, + context: Context + ) { + // Intercept status updates resulting from re-enqueue request, which + // themselves are triggered by a change in WiFi requirement. Only an update that ends + // a run counts: a task collected while enqueued can start before its cancel, and its + // running update must leave the entry for the cancel's update + if ((status.isFinalState() || status == TaskStatus.paused) && + BDPlugin.tasksToReEnqueue.remove(task) + ) { + if (status == TaskStatus.paused || status == TaskStatus.canceled || status == TaskStatus.failed) { + WiFi.reEnqueue( + EnqueueItem( + context, + task, + BDPlugin.notificationConfigJsonStrings[task.taskId], + BDPlugin.localResumeData[task.taskId] + ) + ) + } + if (BDPlugin.tasksToReEnqueue.isEmpty()) { + WiFi.reEnqueue(null) // signal end of batch + } + if (status == TaskStatus.paused || status == TaskStatus.canceled || status == TaskStatus.failed) { + // the task gives up its slot and waits in the holding queue for a new one. + // Posted directly, as a normal update for a canceled task becomes canceled + if (BDPlugin.holdingQueue != null && task.providesStatusUpdates()) { + val enqueuedUpdate = TaskStatusUpdate( + task = task, + taskStatus = TaskStatus.enqueued, + exception = null, + responseBody = null, + responseStatusCode = null, + responseHeaders = null, + mimeType = null, + charSet = null + ) + postOnBackgroundChannel("statusUpdate", task, enqueuedUpdate.argList, onFail = { + storeLocally( + BDPlugin.keyStatusUpdateMap, + task.taskId, + bdJson.encodeToString(enqueuedUpdate), + prefs + ) + }) + } + return + } + } + + // Normal status update + + // Check if the task was 'programmatically' canceled, in which case a TaskStatus.failed may be + // incorrectly generated by the WorkManager, so we change it to 'canceled' + val modifiedStatus = + if (BDPlugin.canceledTaskIds.contains(task.taskId)) TaskStatus.canceled else status + + // A 'failed' progress update is only provided if + // a retry is not needed: if it is needed, a `waitingToRetry` progress update + // will be generated on the Dart side + val retryNeeded = modifiedStatus == TaskStatus.failed && task.retriesRemaining > 0 + var canSendStatusUpdate = true // may become false for cancellations + // if task is in final state, process a final progressUpdate + when (modifiedStatus) { + TaskStatus.complete -> processProgressUpdate( + task, 1.0, prefs + ) + + TaskStatus.failed -> if (!retryNeeded) processProgressUpdate( + task, -1.0, prefs + ) + + TaskStatus.canceled -> { + canSendStatusUpdate = canSendCancellation(task) + if (canSendStatusUpdate) { + BDPlugin.cancelUpdateSentForTaskId[task.taskId] = + currentTimeMillis() + processProgressUpdate( + task, -2.0, prefs + ) + } + } + + TaskStatus.notFound -> processProgressUpdate( + task, -3.0, prefs + ) + + TaskStatus.paused -> processProgressUpdate( + task, -5.0, prefs + ) + + else -> {} + } + + val taskStatusUpdate = + if (modifiedStatus.isFinalState()) { // last update gets all data + TaskStatusUpdate( + task = task, + taskStatus = modifiedStatus, + exception = if (modifiedStatus == TaskStatus.failed) taskException + ?: TaskException(ExceptionType.general) else null, + responseBody = responseBody, + responseStatusCode = if (modifiedStatus == TaskStatus.complete || modifiedStatus == TaskStatus.notFound) responseStatusCode else null, + responseHeaders = responseHeaders?.filterNotNull() + ?.mapKeys { it.key.lowercase() }, + mimeType = mimeType, + charSet = charSet + ) + } else TaskStatusUpdate( // interim updates are limited + task = task, + taskStatus = modifiedStatus, + exception = null, + responseBody = null, + responseStatusCode = null, + responseHeaders = null, + mimeType = null, + charSet = null + ) + + // Post update if task expects one, or if failed and retry is needed + if (canSendStatusUpdate && (task.providesStatusUpdates() || retryNeeded)) { + val statusUpdateJson = bdJson.encodeToString(taskStatusUpdate) + if (modifiedStatus.isFinalState()) { + // Store final status locally before removing task from keyTasksMap so that if + // the process dies before Dart persists the update, resumeFromBackground will + // replay it instead of rescheduleKilledTasks re-enqueuing a finished task + storeLocally( + BDPlugin.keyStatusUpdateMap, + task.taskId, + statusUpdateJson, + prefs + ) + } + val arg = taskStatusUpdate.argList + postOnBackgroundChannel( + "statusUpdate", + task, + arg, + onFail = { + // unsuccessful post, so store in local prefs + Log.d(TAG, "Could not post status update -> storing locally") + storeLocally( + BDPlugin.keyStatusUpdateMap, + task.taskId, + statusUpdateJson, + prefs + ) + }, + onSuccess = if (modifiedStatus.isFinalState()) { + { + removeLocally( + BDPlugin.keyStatusUpdateMap, + task.taskId, + prefs + ) + } + } else null + ) + } + // if task is in final state, cancel the WorkManager job (if failed), + // remove task from persistent storage, clean up references to taskId + // and invoke the onTaskFinishedCallback if necessary + if (modifiedStatus.isFinalState()) { + if (modifiedStatus == TaskStatus.failed) { + // Cancel the WorkManager job. + // This is to avoid the WorkManager restarting a job that was + // canceled because job constraints are violated (e.g. network unavailable) + // We want to manage cancellation ourselves, so we cancel the job + val workManager = WorkManager.getInstance(context) + val operation = workManager.cancelAllWorkByTag("taskId=${task.taskId}") + try { + withContext(Dispatchers.IO) { + operation.result.get() + } + } catch (_: Throwable) { + Log.w( + BDPlugin.TAG, + "Could not kill task wih id ${task.taskId} in operation: $operation" + ) + } + } + BDPlugin.prefsLock.write { + val tasksMap = getTaskMap(prefs) + tasksMap.remove(task.taskId) + prefs.edit { + putString( + BDPlugin.keyTasksMap, + bdJson.encodeToString>(tasksMap) + ) + } + } + QueueService.cleanupTaskId(task.taskId) + if (task.options?.hasOnFinishCallback() == true) { + Callbacks.invokeOnTaskFinishedCallback(context, taskStatusUpdate) + } + } + } + + /** Return true if we can send a cancellation for this task + * + * Cancellation can only be sent if it wasn't already sent by the [BDPlugin] + * in the cancelTasksWithId method. Side effect is to clean out older cancellation entries + * from the [BDPlugin.cancelUpdateSentForTaskId] + */ + private fun canSendCancellation(task: Task): Boolean { + val now = currentTimeMillis() + // Prune stale cancellation entries older than 3 seconds + BDPlugin.cancelUpdateSentForTaskId.entries.removeAll { now - it.value >= 3000 } + return BDPlugin.cancelUpdateSentForTaskId[task.taskId] == null + } + + /** + * Processes a progress update for the [task] + * + * Sends progress update via the background channel to Flutter, if requested + */ + suspend fun processProgressUpdate( + task: Task, progress: Double, prefs: SharedPreferences, expectedFileSize: Long = -1, + downloadSpeed: Double = -1.0, timeRemaining: Long = -1000 + ) { + if (task.providesProgressUpdates()) { + postOnBackgroundChannel( + "progressUpdate", task, + mutableListOf(progress, expectedFileSize, downloadSpeed, timeRemaining), + onFail = + { + // unsuccessful post, so store in local prefs + Log.d(TAG, "Could not post progress update -> storing locally") + storeLocally( + BDPlugin.keyProgressUpdateMap, + task.taskId, + bdJson.encodeToString( + TaskProgressUpdate( + task, + progress, + expectedFileSize + ) + ), + prefs + ) + }) + } + } + + /** + * Send 'canResume' message via the background channel to Flutter + */ + suspend fun processCanResume(task: Task, canResume: Boolean) { + postOnBackgroundChannel("canResume", task, canResume) + } + + /** + * Process resume information + * + * Attempts to post this to the Dart side via background channel. If that is not + * successful, stores the resume data in shared preferences, for later retrieval by + * the Dart side. + * + * Also stores a copy in memory locally, to allow notifications to resume a task + */ + suspend fun processResumeData(resumeData: ResumeData, prefs: SharedPreferences) { + BDPlugin.localResumeData[resumeData.task.taskId] = resumeData + postOnBackgroundChannel( + "resumeData", resumeData.task, mutableListOf( + resumeData.data, + resumeData.requiredStartByte, + resumeData.eTag + ), onFail = + { + // unsuccessful post, so store in local prefs + Log.d(TAG, "Could not post resume data -> storing locally") + storeLocally( + BDPlugin.keyResumeDataMap, + resumeData.task.taskId, + bdJson.encodeToString(resumeData), + prefs + ) + }) + } + + /** + * Store the [item] in preferences under [prefsKey], keyed by [taskId] + */ + private fun storeLocally( + prefsKey: String, + taskId: String, + item: String, + prefs: SharedPreferences + ) { + BDPlugin.prefsLock.write { + // add the data to a map keyed by taskId + val jsonString = prefs.getString(prefsKey, "{}") as String + val mapByTaskId = bdJson.decodeFromString>(jsonString) + mapByTaskId[taskId] = item + prefs.edit { + putString( + prefsKey, bdJson.encodeToString>(mapByTaskId) + ) + } + } + } + + /** + * Remove the entry for [taskId] from preferences under [prefsKey] + */ + private fun removeLocally( + prefsKey: String, + taskId: String, + prefs: SharedPreferences + ) { + BDPlugin.prefsLock.write { + val jsonString = prefs.getString(prefsKey, "{}") as String + val mapByTaskId = bdJson.decodeFromString>(jsonString) + if (mapByTaskId.remove(taskId) != null) { + prefs.edit { + putString( + prefsKey, bdJson.encodeToString>(mapByTaskId) + ) + } + } + } + } + + } + + var task: Task + get() = context.task + set(value) { context.task = value } + + // properties related to pause/resume functionality and progress + var startByte = 0L // actual starting position within the task range, used for resume + var bytesTotal = 0L // total bytes read in this download session + var taskCanResume: Boolean + get() = context.taskCanResume + set(value) { context.taskCanResume = value } + var isResume = false // whether task is a resume + private var bytesTotalAtLastProgressUpdate = 0L + private var lastProgressUpdateTime = 0L // in millis + private var lastProgressUpdate = 0.0 + private var nextProgressUpdateTime = 0L + var networkSpeed: Double + get() = context.networkSpeed + set(value) { context.networkSpeed = value } + private var isTimedOut = false + + // properties related to notifications + var notificationConfigJsonString: String? + get() = context.notificationConfigJsonString + set(value) { context.notificationConfigJsonString = value } + var notificationConfig: NotificationConfig? + get() = context.notificationConfig + set(value) { context.notificationConfig = value } + var notificationId: Int + get() = context.notificationId + set(value) { context.notificationId = value } + var notificationProgress: Double + get() = context.notificationProgress + set(value) { context.notificationProgress = value } + + @Volatile + var activeConnection: HttpURLConnection? = null + + // additional parameters for final TaskStatusUpdate + var taskException: TaskException? = null + var responseBody: String? = null + private var responseHeaders: Map? = null + var responseStatusCode: Int? = null + private var mimeType: String? = null // derived from Content-Type header + private var charSet: String? = null // derived from Content-Type header + + // related to foreground tasks + private var runInForegroundFileSize: Int = -1 + var canRunInForeground = false + var runInForeground: Boolean + get() = context.runInForeground + set(value) { context.runInForeground = value } + private var hasDeliveredResult = false + val isActive: Boolean + get() = !hasDeliveredResult && context.isActive + + lateinit var prefs: SharedPreferences + + /** + * Runner execution entrypoint + */ + open suspend fun run() { + prefs = PreferenceManager.getDefaultSharedPreferences(context.appContext) + runInForegroundFileSize = + prefs.getInt(BDPlugin.keyConfigForegroundFileSize, -1) + withContext(Dispatchers.IO) { + task = context.task + val isExpedited = task.priority < 5 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val timeout = if (isExpedited) expeditedTaskTimeoutMillis else taskTimeoutMillis + val timeoutJob = CoroutineScope(Dispatchers.Default).launch { + delay(timeout) + isTimedOut = true + } + if (task.options?.hasBeforeStartCallback() == true) { + val statusUpdate = Callbacks.invokeBeforeTaskStartCallback(context.appContext, task) + if (statusUpdate != null) { + Log.i( + TAG, + "TaskId ${task.taskId} interrupted by beforeTaskStart callback" + ) + processStatusUpdate( + task, + statusUpdate.taskStatus, + prefs, + taskException = statusUpdate.exception, + responseBody = statusUpdate.responseBody, + responseHeaders = statusUpdate.responseHeaders, + responseStatusCode = statusUpdate.responseStatusCode, + context = context.appContext + ) + if (!slotReleasedByCancel()) { + BDPlugin.holdingQueue?.taskFinished(task) + } + timeoutJob.cancel() + return@withContext // task interrupted + } + } + + var status = TaskStatus.failed + try { + // from here a cancel leaves this run its slot, released in the finally block + context.runId?.let { BDPlugin.startedRunIds.add(it) } + task = getModifiedTask( + context = context.appContext, + task = task + ) + notificationConfig = context.notificationConfig + notificationConfigJsonString = if (notificationConfig != null) bdJson.encodeToString(notificationConfig!!) else null + + canRunInForeground = runInForegroundFileSize >= 0 && + notificationConfig?.running != null // must have notification + isResume = determineIfResume() + Log.i( + TAG, + "${if (isResume) "Resuming" else "Starting"} task with taskId ${task.taskId}" + ) + processStatusUpdate(task, TaskStatus.running, prefs, context = context.appContext) + if (!isResume) { + processProgressUpdate(task, 0.0, prefs) + } + context.updateNotification(task, TaskStatus.running) + status = doTask() + } catch (e: Exception) { + Log.w(TAG, "TaskRunner.run execution failed for taskId ${task.taskId}: $e") + if (e is CancellationException) { + if (BDPlugin.cancelUpdateSentForTaskId.containsKey(task.taskId)) { + status = TaskStatus.canceled + } + // else status remains .failed (system cancellation) + } + setTaskException(e) + } finally { + timeoutJob.cancel() + withContext(NonCancellable) { + // NonCancellable to make sure we clean up even if job is being cancelled + // a pause request that was not acted upon must not affect a later run + BDPlugin.pausedTaskIds.remove(task.taskId) + val slotReleased = slotReleasedByCancel() + processStatusUpdate( + task, + status, + prefs, + taskException, + responseBody, + responseHeaders, + responseStatusCode, + mimeType, + charSet, + context.appContext + ) + if (status != TaskStatus.failed || task.retriesRemaining == 0) { + // update only if not failed, or no retries remaining + context.updateNotification(task, status) + } + if (!slotReleased && (context.runId != null || status != TaskStatus.canceled)) { + // let the holdingQueue know this task is no longer active + // except a canceled UIDT run, handled directly in cancellation and reset methods + BDPlugin.holdingQueue?.taskFinished(task) + } + context.runId?.let { BDPlugin.startedRunIds.remove(it) } + val runId = context.runId + if (context.isTaskStopped && runId != null) { + // WorkManager sets a worker it stopped back to ENQUEUED and runs it again + // later, outside the holding queue. A cancel also stops the worker, and + // leaves its work CANCELLED. Cancelled by id, as cancelling by tag would + // also cancel a newer work for this task + try { + val workManager = WorkManager.getInstance(context.appContext) + val workId = UUID.fromString(runId) + if (workManager.getWorkInfoById(workId).get()?.state != WorkInfo.State.CANCELLED) { + workManager.cancelWorkById(workId).result.get() + Log.i( + TAG, + "Cancelled work $runId of taskId ${task.taskId} after a WorkManager stop" + ) + } + } catch (e: Exception) { + Log.w(TAG, "Could not cancel work $runId of taskId ${task.taskId}: $e") + } + } + } + } + } + hasDeliveredResult = true + } + + /** + * Returns true if a cancel already released this run's holding queue slot, which a + * re-enqueued task may hold by now + */ + private fun slotReleasedByCancel() = + context.runId?.let { BDPlugin.slotReleasedRunIds.remove(it) } == true + + /** Return true if resume is possible - defaults to false */ + open fun determineIfResume(): Boolean { + return false + } + + /** + * Do the task + * + * Sets up the HTTP client to use, creates the connection (but does not + * yet connect) and calls [connectAndProcess] + * + * Returns the [TaskStatus] + */ + private suspend fun doTask(): TaskStatus { + try { + val urlString = task.url + val url = URL(urlString) + val requestTimeoutSeconds = + prefs.getInt(BDPlugin.keyConfigRequestTimeout, 60) + val proxyAddress = + prefs.getString(BDPlugin.keyConfigProxyAddress, null) + val proxyPort = prefs.getInt(BDPlugin.keyConfigProxyPort, 0) + val proxy = if (proxyAddress != null && proxyPort != 0) Proxy( + Proxy.Type.HTTP, + InetSocketAddress(proxyAddress, proxyPort) + ) else null + if (!BDPlugin.haveLoggedProxyMessage) { + Log.i( + TAG, + if (proxy == null) "Not using proxy for any task" + else "Using proxy $proxyAddress:$proxyPort for all tasks" + ) + BDPlugin.haveLoggedProxyMessage = true + } + with(withContext(Dispatchers.IO) { + url.openConnection(proxy ?: Proxy.NO_PROXY) + } as HttpURLConnection) { + activeConnection = this + try { + requestMethod = task.httpRequestMethod + connectTimeout = requestTimeoutSeconds * 1000 + readTimeout = requestTimeoutSeconds * 1000 + for (header in task.headers) { + // For UploadTask, copy headers unless it's "Range" or "Content-Disposition". + // For other task types, copy all headers. + if (!task.isUploadTask() || + (!header.key.equals("Range", ignoreCase = true) && + !header.key.equals("Content-Disposition", ignoreCase = true)) + ) { + setRequestProperty(header.key, header.value) + } + } + return connectAndProcessUntilCanceled(this) + } finally { + activeConnection = null + } + } + } catch (e: Exception) { + Log.w( + TAG, "Error for taskId ${task.taskId}: $e\n${e.stackTraceToString()}" + ) + setTaskException(e) + } + return TaskStatus.failed + } + + /** + * Calls [connectAndProcess], and disconnects the [connection] if this run is cancelled, or + * stopped by WorkManager, before that returns + * + * Blocking reads and writes on the connection ignore coroutine cancellation, so without the + * disconnect a cancelled run keeps transferring, and can complete. The disconnect runs in a + * child of this run's job, not in a coroutineScope, which would turn the status + * [connectAndProcess] returns after a cancel into a CancellationException + */ + private suspend fun connectAndProcessUntilCanceled(connection: HttpURLConnection): TaskStatus { + var finished = false + val disconnectOnCancel = CoroutineScope(currentCoroutineContext()).launch( + start = CoroutineStart.UNDISPATCHED + ) { + try { + awaitCancellation() + } finally { + if (!finished) { + connection.disconnect() + } + } + } + try { + return connectAndProcess(connection) + } finally { + finished = true + disconnectOnCancel.cancel() + } + } + + /** + * Further configures the connection and makes the actual request by + * calling [process], while catching errors + * + * Returns the [TaskStatus] + * */ + open suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus { + try { + if ((task.isDownloadTask() || task.isDataTask()) && task.post != null) { + // binary post holds one byte per character, so encode as Latin-1 + val bytes = + task.post!!.toByteArray(if (task.postIsBinary) Charsets.ISO_8859_1 else Charsets.UTF_8) + connection.doOutput = true + connection.setFixedLengthStreamingMode(bytes.size) + DataOutputStream(connection.outputStream).use { + it.write(bytes) + it.flush() + } + } + return process(connection) + } catch (e: Exception) { + if (BDPlugin.canceledTaskIds.contains(task.taskId)) { + Log.i(TAG, "Task ${task.taskId} was canceled, ignoring exception: ${e.message}") + return TaskStatus.canceled + } + if (BDPlugin.pausedTaskIds.contains(task.taskId)) { + Log.i(TAG, "Task ${task.taskId} was paused, ignoring exception: ${e.message}") + return handlePause() + } + if (isTimedOut && !runInForeground) { + Log.i(TAG, "Task ${task.taskId} timed out, ignoring exception: ${e.message}") + return handleTimeout() + } + if (context.isTaskStopped) { + // stopped by the system (e.g. constraint no longer met), not by + // the user, so fail (as in [transferBytes]) to allow a retry + Log.i(TAG, "Task ${task.taskId} was stopped, ignoring exception: ${e.message}") + taskException = TaskException( + ExceptionType.general, + description = "Task was stopped by the system" + ) + return TaskStatus.failed + } + + setTaskException(e) + when (e) { + is SocketException, is SocketTimeoutException -> Log.i( + TAG, + "Socket exception for taskId ${task.taskId}: ${e.message}" + ) + + is IOException -> Log.w( + TAG, + "FileSystem/IO exception for taskId ${task.taskId}: ${e.message}" + ) + + is CancellationException -> { + if (BDPlugin.cancelUpdateSentForTaskId.containsKey(task.taskId)) { + Log.i( + TAG, + "Canceled task with id ${task.taskId}: ${e.message}" + ) + return TaskStatus.canceled + } else { + Log.i( + TAG, + "WorkManager CancellationException for task with id ${task.taskId} without manual cancellation: failing task" + ) + return TaskStatus.failed + } + } + + else -> { + Log.w( + TAG, + "Error for taskId ${task.taskId}: ${e.message}\n${e.stackTraceToString()}" + ) + taskException = TaskException( + ExceptionType.general, description = + "Error for url ${task.url}: ${e.message}" + ) + } + } + } finally { + // clean up remaining bytes tracking + BDPlugin.remainingBytesToDownload.remove(task.taskId) + } + return TaskStatus.failed + } + + /** + * Handle a pause request that interrupted the task outside of [transferBytes] + * + * Returns the final [TaskStatus], which is [TaskStatus.paused] only if + * resume data was posted. Overridden by subclasses that can pause + */ + open suspend fun handlePause(): TaskStatus { + BDPlugin.pausedTaskIds.remove(task.taskId) + taskException = TaskException( + ExceptionType.resume, + description = "Task was paused but cannot resume" + ) + return TaskStatus.failed + } + + /** + * Handle a timeout that interrupted the task outside of [transferBytes] + * + * Returns the final [TaskStatus], never the special [TaskStatus.enqueued]. + * Overridden by subclasses that can resume after a timeout + */ + open suspend fun handleTimeout(): TaskStatus { + taskException = TaskException(ExceptionType.connection, description = "Task timed out") + return TaskStatus.failed + } + + /** + * Process the [task] using the [connection] + * + * Returns the [TaskStatus] + * + * Overridden by subclasses + */ + open suspend fun process( + connection: HttpURLConnection + ): TaskStatus { + throw NotImplementedError() + } + + /** + * Transfer all bytes from [inputStream] to [outputStream] and provide + * progress updates for the [task] + * + * Will return [TaskStatus.canceled], [TaskStatus.paused], [TaskStatus.failed], + * [TaskStatus.complete], or special [TaskStatus.enqueued] which signals the task timed out + * + * [contentLength] is used to calculate progress. If [contentLength] is 0, no progress updates + * will be provided + */ + suspend fun transferBytes( + inputStream: InputStream, outputStream: OutputStream, contentLength: Long, task: Task + ): TaskStatus { + val dataBuffer = ByteArray(bufferSize) + var numBytes: Int + return withContext(Dispatchers.Default) { + var readerJob: Job? = null + var testerJob: Job? = null + val doneCompleter = CompletableDeferred() + try { + readerJob = launch(Dispatchers.IO) { + try { + while (inputStream.read( + dataBuffer, 0, + bufferSize + ) + .also { numBytes = it } != -1 + ) { + if (!isActive) { + doneCompleter.complete(TaskStatus.failed) + break + } + if (numBytes > 0) { + outputStream.write(dataBuffer, 0, numBytes) + bytesTotal += numBytes + val remainingBytes = + BDPlugin.remainingBytesToDownload[task.taskId] + if (remainingBytes != null) { + BDPlugin.remainingBytesToDownload[task.taskId] = + remainingBytes - numBytes + } + } + val expectedFileSize = contentLength + startByte + val progress = doubleMin( + (bytesTotal + startByte).toDouble() / expectedFileSize, + 0.999 + ) + if (contentLength > 0 && shouldSendProgressUpdate( + progress, + currentTimeMillis() + ) + ) { + updateProgressAndNotify(progress, expectedFileSize, task) + } + } + doneCompleter.complete(TaskStatus.complete) + } catch (e: Exception) { + if (!doneCompleter.isCompleted) { + doneCompleter.completeExceptionally(e) + } + } + } + testerJob = launch { + while (isActive) { + // check if task is stopped (canceled), paused or timed out + if (context.isTaskStopped) { + activeConnection?.disconnect() + Log.w(TAG, "Task ${task.taskId} stopped") + doneCompleter.complete(TaskStatus.failed) + break + } + // 'pause' is signalled by adding the taskId to a static list + if (BDPlugin.pausedTaskIds.contains(task.taskId)) { + activeConnection?.disconnect() + doneCompleter.complete(TaskStatus.paused) + break + } + if (isTimedOut && !runInForeground) { + activeConnection?.disconnect() + // special use of .enqueued status, see [processDownload] + doneCompleter.complete(TaskStatus.enqueued) + break + } + delay(100) + } + } + return@withContext doneCompleter.await() + } catch (e: Exception) { + Log.i(TAG, "Exception for taskId ${task.taskId}: $e") + setTaskException(e) + return@withContext TaskStatus.failed + } finally { + readerJob?.cancelAndJoin() + testerJob?.cancelAndJoin() + } + } + } + + /** + * Returns true if [currentProgress] > [lastProgressUpdate] + 2% and + * [now] > [nextProgressUpdateTime], or if there was progress and + * [now] > [nextProgressUpdateTime] + 2 seconds + */ + open fun shouldSendProgressUpdate(currentProgress: Double, now: Long): Boolean { + return (currentProgress - lastProgressUpdate > 0.02 && + now > nextProgressUpdateTime) || (currentProgress > lastProgressUpdate && + now > nextProgressUpdateTime + 2000) + } + + /** + * Calculate network speed and time remaining, then post an update + * to the Dart side and update the 'running' notification + * + * Mst be called at the appropriate frequency, and will update + * [lastProgressUpdate] and [nextProgressUpdateTime] + */ + suspend fun updateProgressAndNotify( + progress: Double, + expectedFileSize: Long, + task: Task + ) { + val now = currentTimeMillis() + if (task.isParallelDownloadTask()) { + // approximate based on aggregate progress + bytesTotal = (progress * expectedFileSize).toLong() + } + val timeSinceLastUpdate = now - lastProgressUpdateTime + // updates within the same millisecond (e.g. from parallel download chunks) would + // divide by zero and make networkSpeed NaN or Infinity, so they keep the current + // speed, and their bytes count towards the next update + if (timeSinceLastUpdate > 0) { + lastProgressUpdateTime = now + val bytesSinceLastUpdate = bytesTotal - bytesTotalAtLastProgressUpdate + bytesTotalAtLastProgressUpdate = bytesTotal + val currentNetworkSpeed: Double = if (timeSinceLastUpdate > 3600000) + -1.0 else bytesSinceLastUpdate / (timeSinceLastUpdate * 1000.0) + networkSpeed = + if (networkSpeed == -1.0) currentNetworkSpeed else (networkSpeed * 3.0 + currentNetworkSpeed) / 4.0 + } else if (timeSinceLastUpdate < 0) { + lastProgressUpdateTime = now // clock moved backwards + } + val remainingBytes = (1 - progress) * expectedFileSize + val timeRemaining: Long = + if (networkSpeed == -1.0) -1000 else (remainingBytes / networkSpeed / 1000).toLong() + // update progress and notification + processProgressUpdate( + task, + progress, + prefs, + expectedFileSize, + networkSpeed, + timeRemaining + ) + context.updateNotification( + task, + TaskStatus.running, + progress, timeRemaining + ) + lastProgressUpdate = progress + nextProgressUpdateTime = currentTimeMillis() + 500 + } + + + /** + * Determine if this task should run in the foreground + * + * Based on [canRunInForeground] and [contentLength] > [runInForegroundFileSize] + */ + fun determineRunInForeground(task: Task, contentLength: Long) { + val wasRunInForeground = runInForeground + runInForeground = + canRunInForeground && contentLength > (runInForegroundFileSize.toLong() shl 20) + if (runInForeground) { + Log.i(TAG, "TaskId ${task.taskId} will run in foreground") + if (!wasRunInForeground) { + CoroutineScope(Dispatchers.Default).launch { + context.updateNotification(task, TaskStatus.running) + } + } + } + } + + + /** + * Return the response's error content as a String, or null if unable + */ + fun responseErrorContent(connection: HttpURLConnection): String? { + try { + return connection.errorStream.bufferedReader().readText() + } catch (e: Exception) { + Log.i( + TAG, + "Could not read response error content from httpResponseCode ${connection.responseCode}: $e" + ) + } + return null + } + + /** + * Set the [taskException] variable based on Exception [e] + */ + private fun setTaskException(e: Any) { + var exceptionType = ExceptionType.general + if (e is IOException) { + exceptionType = ExceptionType.fileSystem + } + if (e is SocketException || e is SocketTimeoutException) { + exceptionType = ExceptionType.connection + } + taskException = TaskException(exceptionType, description = e.toString()) + } + + /** + * Extract content type from [headers] and set [mimeType] and [charSet] + */ + fun extractContentType(headers: MutableMap>) { + val contentType = headers["content-type"]?.first() + if (contentType != null) { + val regEx = Regex("""(.*);\s*charset\s*=(.*)""") + val match = regEx.find(contentType) + if (match != null) { + mimeType = match.groups[1]?.value + charSet = match.groups[2]?.value + } else { + mimeType = contentType + } + } + } + + /** + * Extract headers from response [headers] and store in [responseHeaders] + * + * Required because the headers object has a List for each key, + * whereas the Dart convention separates values under the same key by + * concatenating them, separated by a comma and a space. + * + * This method sets the global responseHeaders field based on the Dart + * convention. + */ + fun extractResponseHeaders(headers: MutableMap>) { + responseHeaders = headers.mapValues { it.value.joinToString() } + } +} + +/** Return the map of tasks stored in preferences */ +fun getTaskMap(prefs: SharedPreferences): MutableMap { + BDPlugin.prefsLock.read { + val tasksMapJson = prefs.getString(BDPlugin.keyTasksMap, "{}") ?: "{}" + return bdJson.decodeFromString(tasksMapJson) + } +} + +/** + * Returns a [task] that may be modified through callbacks + * + * Callbacks would be attached to the task via its [Task.options] property, and if + * present will be invoked by starting a taskDispatcher on a background isolate, then + * sending the callback request via the MethodChannel + * + * First test is for auth refresh (the onAuth callback), then the onStart callback. Both + * callbacks run in a Dart isolate, and may return a modified task, which will be used + * for the actual task execution + */ +suspend fun getModifiedTask(context: Context, task: Task): Task { + var authTask: Task? = null + val auth = task.options?.auth + if (auth != null) { + // Refresh token if needed + if (auth.isTokenExpired() && auth.hasOnAuthCallback()) { + authTask = withContext(Dispatchers.IO) { + Callbacks.invokeOnAuthCallback(context, task) + } + } + authTask = authTask ?: task // Either original or newly authorized + val newAuth = authTask.options?.auth ?: return authTask + // Insert query parameters and headers + val uri = newAuth.addOrUpdateQueryParams( + url = authTask.url, + queryParams = newAuth.getExpandedAccessQueryParams() + ) + val headers = + authTask.headers.toMutableMap().apply { putAll(newAuth.getExpandedAccessHeaders()) } + authTask = authTask.copyWith(url = uri.toString(), headers = headers) + } + authTask = authTask ?: task + if (task.options?.hasOnStartCallback() != true) { + return authTask + } + // onStart callback + val modifiedTask = withContext(Dispatchers.IO) { + Callbacks.invokeOnTaskStartCallback(context, authTask) + } + return modifiedTask ?: authTask +} + +/** + * Returns the length of the [string] in bytes when utf-8 encoded + */ +fun lengthInBytes(string: String): Int { + return string.toByteArray().size +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/TaskWorker.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/TaskWorker.kt new file mode 100644 index 0000000..4203a58 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/TaskWorker.kt @@ -0,0 +1,231 @@ +package com.bbflight.background_downloader + +import android.app.Notification +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import android.util.Log + +/** + * The worker to execute one task + * + * Wraps [TaskRunner], delegating the actual work to it, and implements + * [TaskJobContext] to provide the context for the [TaskRunner]. + */ +@Suppress("ConstPropertyName") +open class TaskWorker( + val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), TaskJobContext { + + companion object { + const val keyTask = "Task" + const val keyNotificationConfig = "notificationConfig" + const val keyResumeDataData = "tempFilename" + const val keyStartByte = "startByte" + const val keyETag = "eTag" + const val bufferSize = 2 shl 12 + + /** Converts [Task] to JSON string representation */ + fun taskToJsonString(task: Task): String { + return TaskRunner.taskToJsonString(task) + } + + /** + * Processes a change in status for the task + * + * Delegates to [TaskRunner.processStatusUpdate] + */ + suspend fun processStatusUpdate( + task: Task, + status: TaskStatus, + prefs: SharedPreferences, + taskException: TaskException? = null, + responseBody: String? = null, + responseHeaders: Map? = null, + responseStatusCode: Int? = null, + mimeType: String? = null, + charSet: String? = null, + context: Context + ) { + TaskRunner.processStatusUpdate( + task, + status, + prefs, + taskException, + responseBody, + responseHeaders, + responseStatusCode, + mimeType, + charSet, + context + ) + } + } + + override val appContext: Context + get() = applicationContext + override val runId: String + get() = id.toString() + + // TaskJobContext Mutable properties + override lateinit var task: Task + override var notificationConfig: NotificationConfig? = null + override var notificationId: Int = 0 + override var notificationProgress: Double = 2.0 // indeterminate + override var networkSpeed: Double = -1.0 + override var taskCanResume: Boolean = false + override var notificationConfigJsonString: String? = null + override var runInForeground: Boolean = false + + override val isTaskStopped: Boolean + get() = isStopped + + + + override val isActive: Boolean + get() = !isTaskStopped + + override fun getInputLong(key: String, defaultValue: Long): Long { + return inputData.getLong(key, defaultValue) + } + + override fun getInputString(key: String): String? { + return inputData.getString(key) + } + + override suspend fun setForegroundNotification( + notificationId: Int, + notification: Notification, + notificationType: Int + ) { + try { + setForeground(ForegroundInfo(notificationId, notification, notificationType)) + } catch (e: IllegalStateException) { + Log.d("TaskWorker", "Ignoring setForeground: worker already completed. ${e.message}") + } + } + + override suspend fun updateNotification( + task: Task, + status: TaskStatus, + progress: Double, + timeRemaining: Long + ) { + NotificationService.updateNotification(this, status, progress, timeRemaining) + } + + override fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) { + // No-op for TaskWorker + } + + + override suspend fun doWork(): Result { + try { + // Initialize task and notificationConfig from inputData + val taskJson = inputData.getString(keyTask) + if (taskJson != null) { + task = bdJson.decodeFromString(taskJson) + } else { + return Result.failure() + } + + notificationConfigJsonString = inputData.getString(keyNotificationConfig) + if (notificationConfigJsonString != null) { + notificationConfig = bdJson.decodeFromString(notificationConfigJsonString!!) + } + + runInForeground = false + + if (runAttemptCount > 0 && skipReRun()) { + return Result.success() + } + val runner = createRunner() + runner.run() + + return Result.success() + } catch (e: Exception) { + return Result.failure() + } + } + + /** + * Returns true if this WorkManager re-run must end without running its task, because the + * task already finished or another work supersedes it, and gives back a finished task's slot + * + * WorkManager starts a work again after it stopped it, and after a process death for works + * it didn't see finish, without asking the holding queue + */ + private suspend fun skipReRun(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val taskInTasksMap = getTaskMap(prefs).containsKey(task.taskId) + val otherUnfinishedWorkIds = withContext(Dispatchers.IO) { + WorkManager.getInstance(applicationContext) + .getWorkInfosByTag("taskId=${task.taskId}").get() + }.filter { !it.state.isFinished && it.id != id }.map { it.id.toString() } + val attempt = "WorkManager re-run (attempt $runAttemptCount) of taskId ${task.taskId}" + return when (reRunDecision(runAttemptCount, taskInTasksMap, otherUnfinishedWorkIds)) { + ReRunDecision.run -> { + Log.i(BDPlugin.TAG, "Running $attempt") + false + } + + ReRunDecision.skipFinished -> { + Log.i(BDPlugin.TAG, "Skipping $attempt: finished") + // the slot the holding queue counted for this work, unless a cancel released it + if (!BDPlugin.slotReleasedRunIds.remove(runId)) { + BDPlugin.holdingQueue?.taskFinished(task) + } + true + } + + ReRunDecision.skipSuperseded -> { + // the other work holds the task's slot, and releases it when it ends + Log.i(BDPlugin.TAG, "Skipping $attempt: superseded by ${otherUnfinishedWorkIds.first()}") + true + } + } + } + + /** + * Create the appropriate [TaskRunner] for this worker + * + * Must be overridden by subclasses + */ + open fun createRunner(): TaskRunner { + throw NotImplementedError("Subclasses must override createRunner") + } +} + +/** What a WorkManager run of a task does, see [reRunDecision] */ +@Suppress("EnumEntryName") +enum class ReRunDecision { + run, + skipFinished, // the task's final state was already processed + skipSuperseded // another unfinished work for the task exists +} + +/** + * Decides whether a WorkManager run with [runAttemptCount] runs its task, given whether the task + * is still in the tasks map ([taskInTasksMap]) and the ids of other unfinished works for it + * + * A first run (attempt 0) always runs. A re-run is skipped if another work supersedes it, or else + * if its task already finished + */ +fun reRunDecision( + runAttemptCount: Int, + taskInTasksMap: Boolean, + otherUnfinishedWorkIds: List +): ReRunDecision = when { + runAttemptCount == 0 -> ReRunDecision.run + otherUnfinishedWorkIds.isNotEmpty() -> ReRunDecision.skipSuperseded + !taskInTasksMap -> ReRunDecision.skipFinished + else -> ReRunDecision.run +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UIDTJobService.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UIDTJobService.kt new file mode 100644 index 0000000..9833474 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UIDTJobService.kt @@ -0,0 +1,156 @@ +package com.bbflight.background_downloader + +import android.app.Notification +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +class UIDTJobService : JobService() { + + val jobs = java.util.concurrent.ConcurrentHashMap() + val jobContexts = java.util.concurrent.ConcurrentHashMap() + + override fun onStartJob(params: JobParameters?): Boolean { + Log.d(TaskRunner.TAG, "Starting UIDT JobService") + if (params == null) return false + + val extras = params.extras + val taskJson = extras.getString(TaskWorker.keyTask) + if (taskJson == null) { + Log.e(TaskRunner.TAG, "Task JSON not found in job parameters") + return false + } + + val jobContext = UIDTJobContext(this, params) + try { + jobContext.task = bdJson.decodeFromString(taskJson) + jobContext.notificationConfigJsonString = extras.getString(TaskWorker.keyNotificationConfig) + if (jobContext.notificationConfigJsonString != null) { + jobContext.notificationConfig = bdJson.decodeFromString(jobContext.notificationConfigJsonString!!) + } + } catch (e: Exception) { + Log.e(TaskRunner.TAG, "Failed to decode task or notification config: $e") + return false + } + + // Determine runner based on task type. + val runner = when (jobContext.task.taskType) { + "DownloadTask" -> DownloadTaskRunner(jobContext) + "UriDownloadTask" -> DownloadTaskRunner(jobContext) + "UploadTask" -> UploadTaskRunner(jobContext) + "UriUploadTask" -> UploadTaskRunner(jobContext) + "MultiUploadTask" -> UploadTaskRunner(jobContext) + "DataTask" -> DataTaskRunner(jobContext) + "ParallelDownloadTask" -> ParallelDownloadTaskRunner(jobContext) + else -> { + Log.e(TaskRunner.TAG, "Unknown task type: ${jobContext.task.taskType}") + return false + } + } + + jobContexts[params.jobId] = jobContext + val job = CoroutineScope(Dispatchers.IO).launch { + try { + runner.run() + Log.d(TaskRunner.TAG, "UIDT JobService finished for taskId ${jobContext.task.taskId}") + } finally { + jobs.remove(params.jobId) + jobContexts.remove(params.jobId) + jobFinished(params, false) // retries managed internally + NotificationService.onUIDTJobFinished(jobContext) + } + } + jobs[params.jobId] = job + + return true // Work is still running on background thread + } + + override fun onStopJob(params: JobParameters?): Boolean { + Log.i(TaskRunner.TAG, "Stopping UIDT JobService") + if (params != null) { + jobContexts[params.jobId]?.isStopped = true + jobs.remove(params.jobId)?.cancel() + jobContexts.remove(params.jobId) + } + return false // Do not reschedule automatically; background_downloader manages retries internally + } + + /** + * Context for a single job execution, holding the state for that specific task/job + */ + class UIDTJobContext(val service: JobService, val params: JobParameters) : TaskJobContext { + // TaskJobContext Properties + override lateinit var task: Task + override var notificationConfig: NotificationConfig? = null + override var notificationId: Int = 0 + override var notificationProgress: Double = 2.0 + override var networkSpeed: Double = -1.0 + override var taskCanResume: Boolean = false + override var notificationConfigJsonString: String? = null + override val runId: String? = null + override var runInForeground: Boolean = true // UIDT always runs in foreground service + + @Volatile + var isStopped: Boolean = false + + override val appContext: Context + get() = service.applicationContext + + override val isTaskStopped: Boolean + get() = isStopped || !isActive + + override val isActive: Boolean + get() = !isStopped && ((service as? UIDTJobService)?.jobs?.get(params.jobId)?.isActive ?: true) + + + override fun getInputLong(key: String, defaultValue: Long): Long { + return params.extras.getLong(key, defaultValue) + } + + override fun getInputString(key: String): String? { + return params.extras.getString(key) + } + + override suspend fun setForegroundNotification( + notificationId: Int, + notification: Notification, + notificationType: Int + ) { + if (Build.VERSION.SDK_INT >= 34) { + service.setNotification( + params, + notificationId, + notification, + JobService.JOB_END_NOTIFICATION_POLICY_DETACH + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + service.startForeground(notificationId, notification, notificationType) + } else { + service.startForeground(notificationId, notification) + } + } + + override suspend fun updateNotification( + task: Task, + status: TaskStatus, + progress: Double, + timeRemaining: Long + ) { + NotificationService.updateNotification(this, status, progress, timeRemaining) + } + + override fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) { + if (Build.VERSION.SDK_INT >= 34) { + service.updateEstimatedNetworkBytes(params, downloadBytes, uploadBytes) + } + } + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UpdateNotificationWorker.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UpdateNotificationWorker.kt new file mode 100644 index 0000000..88fb76a --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UpdateNotificationWorker.kt @@ -0,0 +1,47 @@ +package com.bbflight.background_downloader + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.work.WorkerParameters +import kotlinx.serialization.json.Json + +/** + * Worker that updates the notification for a task, given a + * NotificationConfig and a TaskStatus. + * + * Used to update a task notification initiated from the Dart side, when + * no actual TaskWorker is active for the task, e.g. when failing for the + * last time on retry. We need a [TaskWorker] in order to call the + * [NotificationService] to update the associated notification. + * + * Note this implementation is only valid for two situations: + * - taskStatus is null -> deletes the notification + * - taskStatus is TaskStatus.failed -> displays error notification if needed + */ +class UpdateNotificationWorker(applicationContext: Context, workerParams: WorkerParameters) : + TaskWorker(applicationContext, workerParams) { + + companion object { + const val keyTaskStatusOrdinal = "taskStatusOrdinal" + } + + override suspend fun doWork(): Result { + task = bdJson.decodeFromString(inputData.getString(keyTask)!!) + notificationConfigJsonString = inputData.getString(keyNotificationConfig) + notificationConfig = + if (notificationConfigJsonString != null) bdJson.decodeFromString(notificationConfigJsonString!!) else null + val taskStatusOrdinal = inputData.getInt(keyTaskStatusOrdinal, -1) + notificationId = task.taskId.hashCode() + if (taskStatusOrdinal == -1) { + // delete notification + with(NotificationManagerCompat.from(applicationContext)) { + cancel(notificationId) + } + } else { + // update notification for this taskStatus + val taskStatus = TaskStatus.entries[taskStatusOrdinal] + NotificationService.updateNotification(this, taskStatus) + } + return Result.success() + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UploadTaskRunner.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UploadTaskRunner.kt new file mode 100644 index 0000000..225391e --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UploadTaskRunner.kt @@ -0,0 +1,653 @@ +package com.bbflight.background_downloader + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import androidx.core.net.toFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.net.HttpURLConnection +import android.database.Cursor + +class UploadTaskRunner(context: TaskJobContext) : TaskRunner(context) { + + companion object { + private val asciiOnlyRegEx = Regex("^[\\x00-\\x7F]+$") + private val jsonStringRegEx = Regex("^\\s*(\\{.*\\}|\\[.*\\])\\s*$") + private val newlineRegEx = Regex("\r\n|\r|\n") + const val boundary = "-----background_downloader-akjhfw281onqciyhnIk" + const val lineFeed = "\r\n" + } + + /** + * Process the upload of the file + * + * If the [Task.post] field is set to "binary" then the file will be uploaded as a byte stream POST + * + * If the [Task.post] field is not "binary" then the file(s) will be uploaded as a multipart POST + * + * Note that the [Task.post] field is just used to set whether this is a binary or multipart + * upload. The bytes that will be posted are derived from the file to be uploaded. + * + * Returns the [TaskStatus] + */ + override suspend fun process( + connection: HttpURLConnection + ): TaskStatus { + connection.doOutput = true + val transferBytesResult = + if (task.post?.lowercase() == "binary") { + processBinaryUpload(connection) + } else { + processMultipartUpload(connection) + } + when (transferBytesResult) { + TaskStatus.canceled -> { + Log.i(TAG, "Canceled taskId ${task.taskId}") + return TaskStatus.canceled + } + + TaskStatus.failed -> { + return TaskStatus.failed + } + + TaskStatus.complete -> { + extractResponseBody(connection) + extractResponseHeaders(connection.headerFields) + responseStatusCode = connection.responseCode + if (connection.responseCode in 200..206) { + Log.i( + TAG, "Successfully uploaded taskId ${task.taskId}" + ) + return TaskStatus.complete + } + Log.i( + TAG, + "Response code ${connection.responseCode} for taskId ${task.taskId}" + ) + val errorContent = responseErrorContent(connection) + taskException = TaskException( + ExceptionType.httpResponse, httpResponseCode = connection.responseCode, + description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage + ) + return if (connection.responseCode == 404) { + TaskStatus.notFound + } else { + TaskStatus.failed + } + } + + else -> { + return TaskStatus.failed + } + } + } + + /** + * Process the binary upload of the file + * + * Content-Disposition header will be: + * - set to 'attachment = "filename"' if the task.headers field does not contain an entry for + * 'Content-Disposition' + * - not set at all (i.e. omitted) if the task.headers field contains an entry for + * 'Content-Disposition' with the value "" (an empty string) + * - set to the value of task.headers['Content-Disposition'] in all other cases + * + * The mime-type will be set to [Task.mimeType] + * + * Returns the [TaskStatus] + */ + private suspend fun processBinaryUpload( + connection: HttpURLConnection + ): TaskStatus { + val (filename, fileUri) = UriUtils.unpack(task.filename) + var resolvedMimeType = task.mimeType + val (fileSize, inputStream) = withContext(Dispatchers.IO) { // Use Dispatchers.IO for file operations + if (fileUri != null) { + try { + if (filename == null) { + // attempt to set a filename for the uploaded file in the task object + val derivedFilename = getFileNameFromUri(fileUri) + if (derivedFilename != null) { + task = task.copyWith(filename = UriUtils.pack(derivedFilename, fileUri)) + } + if (resolvedMimeType.isEmpty()) resolvedMimeType = + getMimeType(derivedFilename ?: "") + } + if (fileUri.scheme != "file") { + // a content:// URI scheme is resolved via the contentResolver + val contentResolver = context.appContext.contentResolver + // Get file size from URI + val fileSize = + contentResolver.query(fileUri, null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + if (sizeIndex != -1) cursor.getLong(sizeIndex) else null + } ?: run { + val message = + "Could not open file or determine file size for URI: $fileUri" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return@withContext Pair( + null, + null + ) // Return nulls to indicate failure + } + if (resolvedMimeType.isEmpty()) resolvedMimeType = + getMimeType(contentResolver, fileUri) + // Get InputStream from URI + val inputStream = contentResolver.openInputStream(fileUri) ?: run { + val message = "Could not open input stream for URI: $fileUri" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return@withContext Pair(null, null) // Return nulls to indicate failure + } + Pair(fileSize, inputStream) + } else { + // a file:// Uri scheme is interpreted as a regular file path + val file = fileUri.toFile() + val fileSize = file.length() + if (resolvedMimeType.isEmpty()) resolvedMimeType = + getMimeType(file.name) + Pair(fileSize, FileInputStream(file)) + } + } catch (e: Exception) { + val message = "Error processing URI: ${task.directory}" + Log.w(TAG, message, e) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return@withContext Pair(null, null) // Return nulls to indicate failure + } + } else { + val filePath = task.filePath(context.appContext) + val file = File(filePath) + if (!file.exists() || !file.isFile) { + val message = "File to upload does not exist: $filePath" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return@withContext Pair(null, null) // Return nulls to indicate failure + } + val fileSize = file.length() + if (fileSize <= 0) { + val message = "File $filePath has 0 length" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return@withContext Pair(null, null) + } + if (resolvedMimeType.isEmpty()) resolvedMimeType = + getMimeType(file.name) + Pair(fileSize, FileInputStream(file)) + } + } + // Check for failures in getting fileSize and inputStream + if (fileSize == null || inputStream == null) { + try { inputStream?.close() } catch (_: Exception) {} + return TaskStatus.failed + } + return inputStream.use { fis -> + // Extract Range header information + var start = 0L + var end = fileSize - 1 // Default to the whole file + val rangeHeader = task.headers["Range"] + if (rangeHeader != null) { + val match = Regex("""bytes=(\d+)-(\d*)""").find(rangeHeader) + if (match != null) { + start = match.groupValues[1].toLong() + if (match.groupValues.size > 2 && match.groupValues[2].isNotEmpty()) { + end = match.groupValues[2].toLong() + } + } else { + val message = "Invalid Range header $rangeHeader" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.general, + description = message + ) + return TaskStatus.failed + } + } + val contentLength = end - start + 1 + determineRunInForeground(task, contentLength) + context.updateEstimatedNetworkBytes(0L, contentLength) + Log.d(TAG, "Binary upload for taskId ${task.taskId}") + connection.setRequestProperty("Content-Type", resolvedMimeType) + val taskContentDisposition = + task.headers["Content-Disposition"] ?: task.headers["content-disposition"] + if (taskContentDisposition != "") { + connection.setRequestProperty( + "Content-Disposition", + taskContentDisposition + ?: ("attachment; filename=\"" + Uri.encode(task.filename) + "\"") + ) + } + connection.setRequestProperty("Content-Length", contentLength.toString()) + connection.setFixedLengthStreamingMode(contentLength) + withContext(Dispatchers.IO) { + if (rangeHeader != null) { + // Special treatment for partial uploads + fis.skip(start) + } + LimitedInputStream(fis, contentLength).use { limitedInputStream -> + DataOutputStream(connection.outputStream.buffered()).use { outputStream -> + return@withContext transferBytes( + limitedInputStream, + outputStream, + if (rangeHeader != null) contentLength else fileSize, + task + ) + } + } + } + } + } + + + /** + * Process the multi-part upload of one or more files, and potential form fields + * + * Form fields are taken from [Task.fields]. If only one file is to be uploaded, + * then [Task.filename] determines the file (can be a file path or a Uri string), + * and [Task.fileField] and [Task.mimeType] are used to set the file field name + * and mime type respectively. + * For MultiUploadTasks, the list of fileField, filePath and mimeType are + * extracted from the [Task.fileField], [Task.filename] and [Task.mimeType] (which + * for MultiUploadTasks contain a JSON encoded list of strings). + * + * The total content length is calculated from the sum of all parts if all files are + * given as file paths or file:// Uris. If one or more files are given as content:// Uris, + * then chunked encoding is used. + * + * The connection is set up, and the bytes for each part are transferred to the host. + * + * Returns the [TaskStatus] + */ + private suspend fun processMultipartUpload( + connection: HttpURLConnection, + ): TaskStatus { + // field portion of the multipart, all in one string + // multiple values should be encoded as '"value1", "value2", ...' + val multiValueRegEx = Regex("""^(?:"[^"]+"\s*,\s*)+"[^"]+"$""") + var fieldsString = "" + for (entry in task.fields.entries) { + // Check if the entry value matches the multiple values format + if (multiValueRegEx.matches(entry.value)) { + // Extract multiple values from entry.value + val valueMatches = Regex(""""([^"]+)"""").findAll(entry.value) + for (match in valueMatches) { + fieldsString += fieldEntry(entry.key, match.groupValues[1]) + } + } else { + // Handle single value for key + fieldsString += fieldEntry(entry.key, entry.value) + } + } + // File portion of the multi-part + // Assumes list of files. If only one file, that becomes a list of length one. + // For each file, determine contentDispositionString, contentTypeString + // and file length or InputStream, so that we can calculate total size of + // upload or use chunked encoding. + val separator = "$lineFeed--$boundary$lineFeed" // between files + val terminator = "$lineFeed--$boundary--$lineFeed" // after last file + val fileUri = UriUtils.uriFromStringValue(task.filename) + val filePath = task.filePath(context.appContext) + // fileData's second field contains either a file path or a Uri string + val filesData = if (filePath.isNotEmpty()) { + listOf( + Triple(task.fileField, fileUri?.toString() ?: filePath, task.mimeType) + ) + } else { + task.extractFilesData(context.appContext) + } + val contentDispositionStrings = ArrayList() + val contentTypeStrings = ArrayList() + val fileLengthsOrStreams = ArrayList>() + try { + var useChunkedEncoding = false + for ((fileField, pathOrUriString, mimeType) in filesData) { + var resolvedMimeType = mimeType // we need to change it if it is empty + try { + val fileUri = UriUtils.uriFromStringValue(pathOrUriString) + val (fileSize, inputStream) = if (fileUri != null) { + if (fileUri.scheme != "file") { + // a content:// URI scheme is resolved via the contentResolver + val contentResolver = context.appContext.contentResolver + // Get file size from URI, or set to null + val fileSize = + contentResolver.query(fileUri, null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1 && cursor.moveToFirst()) cursor.getLong( + sizeIndex + ) else null + } + useChunkedEncoding = useChunkedEncoding || + fileSize == null // Use chunked encoding if file size is unknown + if (mimeType.isEmpty()) { + resolvedMimeType = getMimeType(contentResolver, fileUri) + } + // Get InputStream from URI + val fileInputStream = contentResolver.openInputStream(fileUri) + if (fileInputStream == null) { + val message = "Could not open input stream for URI: $fileUri" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return TaskStatus.failed + } + Log.v(TAG, "Using InputStream from URI $fileUri") + Pair(fileSize, fileInputStream) + } else { + // a file:// Uri scheme is interpreted as a regular file path + val file = fileUri.toFile() + val fileSize = file.length() + if (mimeType.isEmpty()) { + resolvedMimeType = getMimeType(fileUri.toString()) + } + Log.v(TAG, "Using FileInputStream from URI $fileUri") + Pair(fileSize, FileInputStream(file)) + } + } else { + val file = File(pathOrUriString) + if (!file.exists() || !file.isFile) { + Log.w(TAG, "File at $pathOrUriString does not exist") + taskException = TaskException( + ExceptionType.fileSystem, + description = "File to upload does not exist: $pathOrUriString" + ) + return TaskStatus.failed + } + if (mimeType.isEmpty()) { + resolvedMimeType = getMimeType(file.path) + } + Pair(file.length(), FileInputStream(file)) + } + // we now have a possible content length and InputStream for this file + if (!useChunkedEncoding && fileSize == null) { + val message = "Could not determine file size for $pathOrUriString" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return TaskStatus.failed + } + // determine the file name + val name = if (fileUri != null) { + getFileNameFromUri(fileUri) ?: "unknown" + } else { + File(pathOrUriString).name + } + contentDispositionStrings.add( + "Content-Disposition: form-data; name=\"${browserEncode(fileField)}\"; " + + "filename=\"${browserEncode(name)}\"$lineFeed" + ) + if (filesData.size == 1 && task.taskType != "MultiUploadTask") { + // only for single file uploads do we set the task's filename property + task = task.copyWith( + filename = if (fileUri != null) UriUtils.pack( + name, + fileUri + ) else name + ) + } + contentTypeStrings.add("Content-Type: $resolvedMimeType$lineFeed$lineFeed") + fileLengthsOrStreams.add(Pair(fileSize, inputStream)) + } catch (_: Exception) { + val message = + "Could not open file or determine file size for $pathOrUriString" + Log.w(TAG, message) + taskException = TaskException( + ExceptionType.fileSystem, + description = message + ) + return TaskStatus.failed + } + } + + // setup the connection + connection.setRequestProperty("Accept-Charset", "UTF-8") + connection.setRequestProperty("Connection", "Keep-Alive") + connection.setRequestProperty("Cache-Control", "no-cache") + connection.setRequestProperty( + "Content-Type", "multipart/form-data; boundary=$boundary" + ) + if (!useChunkedEncoding) { + // Calculate total content length only if not using chunked encoding + val fileDataLength = + contentDispositionStrings.sumOf { string: String -> lengthInBytes(string) } + + contentTypeStrings.sumOf { string: String -> string.length } + + fileLengthsOrStreams.sumOf { pair -> + pair.first ?: 0 + } + separator.length * contentDispositionStrings.size + 2 + val contentLength = + lengthInBytes(fieldsString) + "--$boundary$lineFeed".length + fileDataLength + determineRunInForeground(task, contentLength) + context.updateEstimatedNetworkBytes(0L, contentLength) + connection.setRequestProperty("Content-Length", contentLength.toString()) + connection.setFixedLengthStreamingMode(contentLength) + } else { + determineRunInForeground(task, 1024 * 1024 * 20) // assume at least 20MB + connection.setChunkedStreamingMode(0) // Use default chunk size + } + connection.useCaches = false + // transfer the bytes + return withContext(Dispatchers.IO) { + DataOutputStream(connection.outputStream).use { outputStream -> + val writer = outputStream.writer() + // write form fields + writer.append(fieldsString).append("--${boundary}").append(lineFeed) + // write each file + for (i in filesData.indices) { + fileLengthsOrStreams[i].second.use { inputStream -> + if (inputStream != null) { + writer.append(contentDispositionStrings[i]) + .append(contentTypeStrings[i]).flush() + val transferBytesResult = + transferBytes( + inputStream, + outputStream, + fileLengthsOrStreams[i].first ?: 0, + task + ) + if (transferBytesResult == TaskStatus.complete) { + if (i < filesData.size - 1) { + writer.append(separator) + } else + writer.append(terminator) + writer.flush() + } else { + return@withContext transferBytesResult + } + } else { + Log.w(TAG, "No input stream for ${filesData[i].first}") + taskException = TaskException( + ExceptionType.fileSystem, + description = "No input stream for ${filesData[i].first}" + ) + return@withContext TaskStatus.failed + } + } + } + writer.close() + } + return@withContext TaskStatus.complete + } + } finally { + for (pair in fileLengthsOrStreams) { + try { + pair.second?.close() + } catch (_: Exception) {} + } + } + } + + /** + * Returns the file name for the [uri] using the [ContentResolver], or the last path segment, + * or null + */ + private fun getFileNameFromUri(uri: Uri): String? { + if (uri.scheme == "content") { + val cursor = context.appContext.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + return it.getString(nameIndex) + } + } + } + } + return uri.lastPathSegment + } + + /** + * Returns the mime type for the [uri] using the [ContentResolver] or extension + */ + fun getMimeType(contentResolver: ContentResolver, uri: Uri): String { + // Try to get it directly from ContentResolver + var mimeType = contentResolver.getType(uri) + if (mimeType != null) return mimeType + // Try to infer it from the URL file extension if available + return getMimeType(uri.toString()) + } + + /** + * Helper to get mimeType from extension + */ + private fun getMimeType(url: String): String { + var type: String? = null + val extension = android.webkit.MimeTypeMap.getFileExtensionFromUrl(url) + if (extension != null) { + type = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } + return type ?: "application/octet-stream" + } + + + /** + * Extract the response's body content as a String, or null if unable, and store + * in [responseBody] + */ + private fun extractResponseBody(connection: HttpURLConnection) { + try { + responseBody = connection.inputStream.bufferedReader().readText() + return + } catch (e: Exception) { + Log.i( + TAG, + "Could not read response body from httpResponseCode ${connection.responseCode}: $e" + ) + } + responseBody = null + } + + /** + * Returns the multipart entry for one field name/value pair + */ + private fun fieldEntry(name: String, value: String): String { + return "--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed" + } + + /** + * Returns the header string for a field + * + * The return value is guaranteed to contain only ASCII characters + */ + private fun headerForField(name: String, value: String): String { + var header = "Content-Disposition: form-data; name=\"${browserEncode(name)}\"" + if (isJsonString(value)) { + header = "$header\r\n" + + "Content-Type: application/json; charset=utf-8" + } else if (!isPlainAscii(value)) { + header = "$header\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: binary" + } + return "$header\r\n\r\n" + } + + /** + * Returns whether [string] is composed entirely of ASCII-compatible characters + */ + private fun isPlainAscii(string: String): Boolean { + return asciiOnlyRegEx.matches(string) + } + + /** + * Returns whether [string] is a JSON formatted string + */ + private fun isJsonString(string: String): Boolean { + return jsonStringRegEx.matches(string) + } + + /** + * Encode [value] in the same way browsers do + */ + private fun browserEncode(value: String): String { + // http://tools.ietf.org/html/rfc2388 mandates some complex encodings for + // field names and file names, but in practice user agents seem not to + // follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as + // `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII + // characters). We follow their behavior. + return value.replace(newlineRegEx, "%0D%0A").replace("\"", "%22") + } +} + +/** + * InputStream that limits the number of bytes read + */ +class LimitedInputStream( + private val inputStream: InputStream, + private val limit: Long +) : InputStream() { + + private var bytesRead: Long = 0 + + override fun read(): Int { + if (bytesRead >= limit) { + return -1 // End of stream + } + val result = inputStream.read() + if (result != -1) { + bytesRead++ + } + return result + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (bytesRead >= limit) { + return -1 + } + // Adjust length to not exceed limit + val remainingBytes: Long = limit - bytesRead + val maxBytesToRead = + minOf(len.toLong(), remainingBytes).toInt() + val result = inputStream.read(b, off, maxBytesToRead) + + if (result != -1) { + bytesRead += result + } + return result + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UploadTaskWorker.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UploadTaskWorker.kt new file mode 100644 index 0000000..09df895 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UploadTaskWorker.kt @@ -0,0 +1,12 @@ +package com.bbflight.background_downloader + +import android.content.Context +import androidx.work.WorkerParameters + +class UploadTaskWorker(context: Context, params: WorkerParameters) : + TaskWorker(context, params) { + + override fun createRunner(): TaskRunner { + return UploadTaskRunner(this) + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UriUtils.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UriUtils.kt new file mode 100644 index 0000000..b1fbc42 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/UriUtils.kt @@ -0,0 +1,991 @@ +package com.bbflight.background_downloader + + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.ext.SdkExtensions +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import java.io.File +import java.io.IOException + + +object UriUtils { + + /** + * Packs [filename] and [uri] into a single String. + * + * Use [unpack] to retrieve the filename and uri from the packed String. + */ + fun pack(filename: String, uri: Uri): String = ":::$filename::::::$uri:::" + + /** + * Unpacks [packedString] into a filename and uri. If this is not a packed + * string, returns the original [packedString] as the filename and null. + */ + fun unpack(packedString: String): Pair { + val regex = Regex(":::([\\s\\S]*?)::::::([\\s\\S]*?):::") + val match = regex.find(packedString) + + return if (match != null && match.groupValues.size == 3) { + val filename = match.groupValues[1] + val uriString = match.groupValues[2] + val uri = Uri.parse(uriString) + val scheme = uri?.scheme + Pair(filename, if (scheme?.isNotEmpty() == true) uri else null) + } else { + val uri = Uri.parse(packedString) + val scheme = uri?.scheme + if (scheme?.isNotEmpty() == true) Pair(null, uri) + else Pair(packedString, null) + } + } + + /** + * Returns the Uri represented by [value], or null if the String is not a + * valid Uri or packed Uri string. + * + * [value] should be a full Uri string, or a packed String containing + * a Uri (see [pack]). + */ + fun uriFromStringValue(value: String): Uri? { + val (_, uri) = unpack(value) + return uri + } + + /** + * Returns true if [value] is a valid Uri or packed Uri string. + * + * [value] should be a full Uri string, or a packed String containing + * a Uri (see [pack]). + */ + fun containsUri(value: String): Boolean = uriFromStringValue(value) != null +} + +/** + * Handles method calls from Flutter related to file and directory picking/creation. + */ +class UriUtilsMethodCallHelper(private val plugin: BDPlugin) : MethodCallHandler { + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val activity = plugin.activity + if (activity == null) { + result.error("NO_ACTIVITY", "No activity found", null) + return + } + + when (call.method) { + "pickDirectory" -> { + /** + * results in selected directory URI (or null) for the [SharedStorage] starting location, which + * may be null for no selection. If persistedUriPermission is true, the URI will be persisted + * + * Arguments are startLocationOrdinal (of [SharedStorage]) and persistedUriPermission (bool) + */ + val args = call.arguments as? List<*> + val startLocationOrdinal = args?.get(0) as Int? + val startLocationUriString = args?.get(1) as? String + val persistedUriPermission = args?.get(2) as? Boolean == true + val startLocation = + if (startLocationOrdinal != null) SharedStorage.entries[startLocationOrdinal] else null + val startLocationUri = startLocationUriString?.toUri() + + if (!DirectoryPicker.pickDirectory( + activity, + startLocation, + startLocationUri, + persistedUriPermission, + result + ) + ) { + result.error( + "PICK_DIRECTORY_FAILED", + "Failed to launch directory picker", + null + ) + } + } + + "pickFiles" -> { + /** + * Results in selected file URI(s) for the [SharedStorage] starting location, which + * may be null for no selection. If allowedExtensions is not null, only files with the + * given extensions will be allowed. If multipleAllowed is true, multiple files can be + * selected. If persistedUriPermission is true, the URI will be persisted. + * + * Arguments are startLocationOrdinal (of [SharedStorage]), allowedExtensions (list of + * strings, or null), multipleAllowed (bool), and persistedUriPermission (bool). + */ + val args = call.arguments as? List<*> + val startLocationOrdinal = args?.get(0) as Int? + val startLocationUriString = args?.get(1) as? String + val allowedExtensionsAnyList = args?.get(2) as? List<*> + val allowedExtensions = allowedExtensionsAnyList?.map { it as String } + val multipleAllowed = args?.get(3) as? Boolean == true + val persistedUriPermission = args?.get(4) as? Boolean == true + + val startLocation = + if (startLocationOrdinal != null) SharedStorage.entries[startLocationOrdinal] else null + val startLocationUri = startLocationUriString?.toUri() + + if (startLocation == SharedStorage.images || startLocation == SharedStorage.video) { + if (!FilePicker.pickMedia( + activity, + startLocation, + multipleAllowed, + persistedUriPermission, + result + ) + ) { + result.error("PICK_FILES_FAILED", "Failed to launch media picker", null) + } + } else { + if (!FilePicker.pickFiles( + activity, + startLocation, + startLocationUri, + allowedExtensions, + multipleAllowed, + persistedUriPermission, + result + ) + ) { + result.error("PICK_FILES_FAILED", "Failed to launch file picker", null) + } + } + } + + "createDirectory" -> { + /** + * Creates a new directory with the given name inside the specified parent directory + * URI. Results in the URI of the new directory + * + * Arguments are parentDirectoryUri (string), newDirectoryName (string), and + * persistedUriPermission (bool). + */ + val args = call.arguments as? List<*> + val parentDirectoryUriString = args?.get(0) as? String + val newDirectoryName = args?.get(1) as? String + val persistedUriPermission = args?.get(2) as? Boolean == true + + if (parentDirectoryUriString == null || newDirectoryName == null) { + result.error( + "INVALID_ARGUMENTS", + "Parent directory URI and new directory name are required", + null + ) + return + } + val parentDirectoryUri = Uri.parse(parentDirectoryUriString) + DirectoryCreator.createDirectory( + activity, + parentDirectoryUri, + newDirectoryName, + persistedUriPermission, + result + ) + } + + "getFileBytes" -> { + val uriString = call.arguments as? String + if (uriString == null) { + result.error("INVALID_ARGUMENTS", "URI string is required", null) + return + } + val fileBytes = getFile(activity, Uri.parse(uriString)) + if (fileBytes != null) { + result.success(fileBytes) + } else { + result.error("GET_FILE_FAILED", "Failed to get file", null) + } + } + + "copyFile" -> { + val args = call.arguments as? List<*> + val sourceUriString = args?.get(0) as? String + val destinationUriString = args?.get(1) as? String + copyFile(activity, sourceUriString, destinationUriString, result) + } + + "moveFile" -> { + val args = call.arguments as? List<*> + val sourceUriString = args?.get(0) as? String + val destinationUriString = args?.get(1) as? String + moveFile(activity, sourceUriString, destinationUriString, result) + } + + "deleteFile" -> { + val uriString = call.arguments as? String + if (uriString == null) { + result.error("INVALID_ARGUMENTS", "URI string is required", null) + return + } + val uri = Uri.parse(uriString) + if (uri.scheme == "content") { + val docFile = DocumentFile.fromSingleUri(activity, uri) + if (docFile != null && docFile.delete()) { + result.success(null) + } else { + if (docFile == null) { + result.error("DELETE_FILE_FAILED", "File at $uri does not exist", null) + } else { + result.error("DELETE_FILE_FAILED", "Failed to delete file", null) + } + } + return + } + if (uri.scheme == "file") { + val file = File(uri.path!!) + if (file.delete()) { + result.success(null) + } else { + result.error("DELETE_FILE_FAILED", "Failed to delete file", null) + } + return + } + result.error("DELETE_FILE_FAILED", "Invalid URI: $uri", null) + } + + "openFile" -> { + /** + * Opens the file at the given URI. + * + * Arguments are uriString (string) and mimeType (string, nullable). + */ + val args = call.arguments as? List<*> + val uriString = args?.get(0) as? String + val mimeType = args?.get(1) as? String + + if (uriString == null) { + result.error("INVALID_ARGUMENTS", "URI string is required", null) + return + } + + if (!doOpenFile(activity, uriString, mimeType ?: getMimeType(uriString))) { + result.error("OPEN_FILE_FAILED", "Failed to open file", null) + } + result.success(true) + } + + else -> result.notImplemented() + } + } + + /** + * Gets the file content as a byte array for a given URI. + * + * @param context The application context. + * @param uri The URI of the file. + * @return The file content as a byte array, or null if an error occurred. + */ + private fun getFile(context: Context, uri: Uri): ByteArray? { + return try { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } catch (e: IOException) { + Log.e(BDPlugin.TAG, "Error reading file: $uri", e) + null + } + } + + /** + * Copies the file from the source URI to the destination URI. Handles file:// and content:// schemes + * for both source and destination. + */ + private fun copyFile( + activity: Activity, + sourceUriString: String?, + destinationUriString: String?, + result: MethodChannel.Result + ) { + if (sourceUriString == null || destinationUriString == null) { + result.error( + "INVALID_ARGUMENTS", + "Source and destination URI strings are required", + null + ) + return + } + + val sourceUri = Uri.parse(sourceUriString) + val destinationUri = Uri.parse(destinationUriString) + try { + activity.contentResolver?.openInputStream(sourceUri)?.use { input -> + activity.contentResolver?.openOutputStream(destinationUri)?.use { output -> + input.copyTo(output) + } ?: result.error( + "COPY_FAILED", + "Failed to open output stream for destination URI", + null + ) + } ?: result.error("COPY_FAILED", "Failed to open input stream for source URI", null) + + result.success(destinationUriString) // Return the destination URI as a String + } catch (e: Exception) { + result.error("COPY_FAILED", "Error copying file: ${e.message}", null) + } + } + + /** + * Tries to move a file using [DocumentsContract.moveDocument] if possible (API >= 24 and content URIs), + * otherwise falls back to a copy-and-delete strategy. Handles file:// and content:// schemes + * for both source and destination. + */ + private fun moveFile( + activity: Activity, + sourceUriString: String?, + destinationUriString: String?, + result: MethodChannel.Result + ) { + if (sourceUriString == null || destinationUriString == null) { + result.error( + "INVALID_ARGUMENTS", + "Source and destination URI strings are required", + null + ) + return + } + val sourceUri = Uri.parse(sourceUriString) + val destinationUri = Uri.parse(destinationUriString) + // Try using DocumentsContract.moveDocument first (if applicable). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && sourceUri.scheme == "content" && destinationUri.scheme == "content") { + try { + val sourceDocument = DocumentFile.fromSingleUri(activity, sourceUri) + if (sourceDocument == null || !sourceDocument.exists()) { + result.error("MOVE_FAILED", "Source file does not exist", null) + return + } + + val destinationParent = DocumentFile.fromTreeUri( + activity, destinationUri.buildUpon().path( + destinationUri.pathSegments.dropLast(1).joinToString("/") + ).build() + ) ?: DocumentFile.fromSingleUri( + activity, destinationUri.buildUpon().path( + destinationUri.pathSegments.dropLast(1).joinToString("/") + ).build() + ) + if (destinationParent == null || !destinationParent.isDirectory) { + result.error( + "MOVE_FAILED", + "Destination directory does not exist or is invalid", + null + ) + return + } + val movedDocument = DocumentsContract.moveDocument( + activity.contentResolver, + sourceUri, + sourceDocument.parentFile!!.uri, + destinationParent.uri + ) + if (movedDocument != null) { + val renamedFile = DocumentsContract.renameDocument( + activity.contentResolver, + movedDocument, + destinationUri.lastPathSegment.toString() + ) + result.success(renamedFile.toString()) + return + } else { + result.error("MOVE_FAILED", "Failed to move file using DocumentsContract", null) + return + } + + } catch (e: Exception) { + Log.i( + BDPlugin.TAG, + "Error moving file (DocumentsContract) from $sourceUri to $destinationUri", + e + ) + } + } + // Fallback: Copy and Delete (works for mixed schemes and older Android versions). + try { + val success = copyForMove( + activity, + sourceUri, + destinationUri, + result + ) // perform the copy operation + if (success) { + var deleteSuccess = false + if (sourceUri.scheme == "content") { + val sourceDocument = DocumentFile.fromSingleUri(activity, sourceUri) + // Use DocumentsContract.deleteDocument if available. + if (sourceDocument != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + deleteSuccess = try { + DocumentsContract.deleteDocument( + activity.contentResolver, + sourceUri + ) + } catch (_: Exception) { + // Try to delete using the document (if deleteDocument fails) + sourceDocument.delete() + } + } else if (sourceDocument != null) { + deleteSuccess = sourceDocument.delete() + } + } else if (sourceUri.scheme == "file") { + val sourceFile = File(sourceUri.path!!) + deleteSuccess = sourceFile.delete() + } + if (deleteSuccess) { + result.success(destinationUriString) + } else { + result.error("MOVE_FAILED", "Failed to delete the source", null) + } + } + } catch (e: Exception) { + result.error("MOVE_FAILED", "Error moving file: ${e.message}", null) + } + } + + /** + * Private helper function to perform copy, to be used in move. + * returns true if successful + */ + private fun copyForMove( + activity: Activity, + sourceUri: Uri, + destinationUri: Uri, + result: MethodChannel.Result + ): Boolean { + try { + if (destinationUri.scheme == "file") { + val destFile = File(destinationUri.path!!) + if (!destFile.parentFile!!.exists()) { + if (!destFile.parentFile!!.mkdirs()) { + result.error( + "MOVE_FAILED", + "Could not create destination directory at ${destFile.parentFile!!.absolutePath}", + null + ) + return false + } + } + } + activity.contentResolver?.openInputStream(sourceUri)?.use { input -> + activity.contentResolver?.openOutputStream(destinationUri)?.use { output -> + input.copyTo(output) + } ?: run { + result.error( + "MOVE_FAILED", + "Failed to open output stream for destination URI", + null + ) + return false + } + } ?: run { + result.error("MOVE_FAILED", "Failed to open input stream for source URI", null) + return false + } + } catch (e: Exception) { + result.error("MOVE_FAILED", "Error copying file: ${e.message}", null) + return false + } + return true // copy was successful + } +} + + +/** + * Helper class for launching the Android file picker to select a directory. + */ +object DirectoryPicker { + const val TAG = "DirectoryPicker" + private var pendingResult: MethodChannel.Result? = null + private var persistedUriPermission: Boolean = false + private const val REQUEST_CODE_PICK_DIRECTORY = 165465106 + + + /** + * Launches the Android file picker to select a directory. + * + * @param activity The current activity. + * @param startLocation The shared storage location to start the picker in (ignored on API levels below Q). + * @param result The MethodChannel result to complete when the picker is closed. + * + * Returns true if the picker was launched successfully, false otherwise. + * + * The result of the picker is handled in [handleActivityResult], and is either + * a URI string, null (if the picker was cancelled) or an error + */ + fun pickDirectory( + activity: Activity, + startLocation: SharedStorage?, + startLocationUri: Uri?, + persistedUriPermission: Boolean = false, + result: MethodChannel.Result + ): Boolean { + if (pendingResult != null) { + Log.w(TAG, "Directory picker already in progress") + return false + } + pendingResult = result + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (persistedUriPermission) { + flags = flags or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + } + // Set the starting directory (for API 26+). + if ((startLocation != null || startLocationUri != null) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + if (startLocationUri != null) startLocationUri else getInitialDirectoryUri( + startLocation!! + ) + ) + } + } + this.persistedUriPermission = persistedUriPermission // for later reference + activity.startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY) + return true + } + + /** + * Handles the result from the directory picker activity, buy setting the [pendingResult] to the + * result value. Returns null if the request was cancelled by the user, and errors if something + * unusual happened + * + * @param requestCode The request code passed to startActivityForResult(). + * @param resultCode The result code returned by the child activity. + * @param data An Intent, which can return result data to the caller. + * @return True if the result was handled, false otherwise. + */ + fun handleActivityResult( + context: Context, + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { + if (requestCode == REQUEST_CODE_PICK_DIRECTORY) { + if (resultCode == Activity.RESULT_OK) { + val directoryUri = data?.data + if (persistedUriPermission && directoryUri != null) { + context.contentResolver.takePersistableUriPermission( + directoryUri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + pendingResult?.success(directoryUri?.toString()) + } else { + pendingResult?.success(null) + } + pendingResult = null // cancelled + return true + } + return false + } +} + +/** + * Helper class for launching the Android file picker to select one or more files. + */ +object FilePicker { + const val TAG = "FilePicker" + private var pendingResult: MethodChannel.Result? = null + private var multipleAllowed: Boolean = false + private var persistedUriPermission: Boolean = false + private const val REQUEST_CODE_PICK_FILES = 265465106 + private const val REQUEST_CODE_PICK_MEDIA = 36546510 + + /** + * Launches the Android file picker to select one or more files. Only read access is granted + * to these files + * + * @param activity The current activity. + * @param startLocation The shared storage location to start the picker in (ignored on API levels below Q). + * @param allowedExtensions A list of allowed file extensions (without the dot), e.g., ["pdf", "jpg", "png"]. If null, all file types are allowed. + * @param multipleAllowed Whether to allow the user to select multiple files. + * @param persistedUriPermission Whether to persist the URI permission across device reboots. + * @param result The MethodChannel result to complete when the picker is closed. + * + * Returns true if the picker was launched successfully, false otherwise. + * + * The result posted back is either a list of URIs as a String, or null (if the user has + * cancelled the picker) or an error + */ + fun pickFiles( + activity: Activity, + startLocation: SharedStorage?, + startLocationUri: Uri?, + allowedExtensions: List?, + multipleAllowed: Boolean = false, + persistedUriPermission: Boolean = false, + result: MethodChannel.Result + ): Boolean { + if (pendingResult != null) { + Log.w(TAG, "File picker already in progress") + return false + } + this.multipleAllowed = multipleAllowed + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multipleAllowed) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + if (persistedUriPermission) { + flags = flags or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + } + + if (!allowedExtensions.isNullOrEmpty()) { + val mimeTypes = extensionsToMimeTypes(allowedExtensions) + if (mimeTypes.size == 1) { + type = mimeTypes[0] + } else { + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } + } else { + type = "*/*" // Allow all file types if no extensions are specified + } + + // Set the starting directory (for API 26+). + if ((startLocation != null || startLocationUri != null) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + if (startLocationUri != null) startLocationUri else getInitialDirectoryUri( + startLocation!! + ) + ) + } + } + this.persistedUriPermission = persistedUriPermission // for later reference + this.multipleAllowed = multipleAllowed // for later reference + pendingResult = result + activity.startActivityForResult(intent, REQUEST_CODE_PICK_FILES) + return true + } + + /** + * Launches the Android photo picker to select one or more media files (images or videos). + * + * @param activity The current activity. + * @param startLocation The shared storage location to start the picker in (images or video). + * @param multipleAllowed Whether to allow the user to select multiple files. + * @param persistedUriPermission Whether to persist the URI permission across device reboots. + * @param result The MethodChannel result to complete when the picker is closed. + * + * Returns true if the picker was launched successfully, false otherwise. + * + * The result posted back is either a list of URIs as a String, or null (if the user has + * cancelled the picker) or an error + */ + fun pickMedia( + activity: Activity, + startLocation: SharedStorage, + multipleAllowed: Boolean = false, + persistedUriPermission: Boolean = false, + result: MethodChannel.Result + ): Boolean { + if (pendingResult != null) { + Log.w(TAG, "Media picker already in progress") + return false + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.R + ) >= 2 + ) { + // on Android version >R.2 we use the new photo picker + val photoPickerIntent = Intent(MediaStore.ACTION_PICK_IMAGES).apply { + if (multipleAllowed) { + val maxImages = MediaStore.getPickImagesMaxLimit() + putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxImages) + } + } + photoPickerIntent.apply { + if (startLocation == SharedStorage.images) { + type = "image/*" + } else if (startLocation == SharedStorage.video) { + type = "video/*" + } + } + this.persistedUriPermission = persistedUriPermission // for later reference + this.multipleAllowed = multipleAllowed // for later reference + pendingResult = result + activity.startActivityForResult(photoPickerIntent, REQUEST_CODE_PICK_MEDIA) + return true + } else { + // for older Android versions we use the filePicker with image or video extensions + return pickFiles( + activity, + startLocation, + null, + if (startLocation == SharedStorage.images) listOf( + "jpg", + "jpeg", + "png", + "gif", + "webp", + "bmp", + "heic", + "heif", + "svg" + ) else listOf("mp4", "webm", "ogg", "webm", "mov", "avi", "mkv"), + multipleAllowed, + persistedUriPermission, + result + ) + } + } + + /** + * Handles the result from the file picker activity, by setting the [pendingResult] to the + * result value. Grants read access only + * + * @param context The context, needed to persist permissions + * @param requestCode The request code passed to startActivityForResult(). + * @param resultCode The result code returned by the child activity. + * @param data An Intent, which can return result data to the caller. + * @return True if the result was handled, false otherwise. + */ + fun handleActivityResult( + context: Context, + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { + if (requestCode == REQUEST_CODE_PICK_FILES || requestCode == REQUEST_CODE_PICK_MEDIA) { + if (resultCode == Activity.RESULT_OK) { + val uris = mutableListOf() + if (multipleAllowed && data?.clipData != null) { + // Multiple files selected + val clipData = data.clipData!! + for (i in 0 until clipData.itemCount) { + val uri = clipData.getItemAt(i).uri + if (persistedUriPermission) { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + uris.add(uri.toString()) + } + } else if (data?.data != null) { + // Single file selected + val uri = data.data!! + if (persistedUriPermission) { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + uris.add(uri.toString()) + } + pendingResult?.success(uris) + } else { + pendingResult?.success(null) // cancelled + } + pendingResult = null // Reset the pending result + return true + } + return false + } + + + /** + * Converts a list of file extensions to a list of corresponding MIME types using the provided `getMimeType` function. + * + * @param extensions The list of file extensions (without the dot). + * @return A list of MIME types. + */ + private fun extensionsToMimeTypes(extensions: List): Array { + return extensions.map { extension -> + if (extension.startsWith('.')) + getMimeType(extension) + else getMimeType("file.$extension") + }.toSet().toTypedArray() + } +} + +/** + * Helper class for creating a new directory using the Android SAF or file system. + */ +object DirectoryCreator { + const val TAG = "DirectoryCreator" + + /** + * Creates a new directory within the given parent directory. + * If the new directory path contains multiple levels, it creates all intermediate directories as needed. + * + * @param context The application context. + * @param parentDirectoryUri The URI of the parent directory (file:// or content://). + * @param newDirectoryName The name of the new directory to create (can be a path). + * @param persistedUriPermission Whether to persist the URI permission across device reboots. + * @param result The MethodChannel result to complete when the operation is finished. + * + * Returns true if the directory creation was initiated successfully, false otherwise. + * + * The result posted back is either a URI string, or an error + */ + fun createDirectory( + context: Context, + parentDirectoryUri: Uri, + newDirectoryName: String, + persistedUriPermission: Boolean, + result: MethodChannel.Result + ) { + if (parentDirectoryUri.scheme == "file") { + // Handle file scheme URIs using standard file operations + val parentDir = File(parentDirectoryUri.path!!) + if (!parentDir.exists() || !parentDir.isDirectory) { + result.error( + "INVALID_PARENT_URI", + "Invalid or inaccessible parent directory URI", + null + ) + return + } + val sanitizedDirectoryName = newDirectoryName.trim(File.separatorChar) + val newDir = File(parentDir, sanitizedDirectoryName) + try { + if (!newDir.exists()) { + if (!newDir.mkdirs()) { + result.error( + "CREATE_FAILED", + "Failed to create directory: ${newDir.absolutePath}", + null + ) + return + } + } + result.success(Uri.fromFile(newDir).toString()) + } catch (e: Exception) { + result.error( + "CREATE_FAILED", + "Error creating directory: ${e.message}", + null + ) + } + } else { + // handle content Uri scheme using DocumentFile API + val parentDir = DocumentFile.fromTreeUri(context, parentDirectoryUri) + if (parentDir == null || !parentDir.exists() || !parentDir.isDirectory) { + result.error( + "INVALID_PARENT_URI", + "Invalid or inaccessible parent directory URI", + null + ) + return + } + + // Sanitize the new directory name by removing leading/trailing path separators + val sanitizedDirectoryName = newDirectoryName.trim(File.separatorChar) + + try { + var currentDir: DocumentFile = parentDir + val directoryPathParts = sanitizedDirectoryName.split(File.separatorChar) + for (directoryName in directoryPathParts) { + var newDir = currentDir.findFile(directoryName) + if (newDir == null || !newDir.exists()) { + newDir = currentDir.createDirectory(directoryName) + } else if (!newDir.isDirectory) { + result.error( + "INVALID_PATH", + "Invalid path: $directoryName is not a directory", + null + ) + return + } + if (newDir == null) { + result.error( + "CREATE_FAILED", + "Failed to create directory: $directoryName", + null + ) + return + } + currentDir = newDir + // Grant permissions to intermediate directories as well + if (persistedUriPermission) { + context.contentResolver.takePersistableUriPermission( + newDir.uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + // Return the URI of the final directory + result.success(currentDir.uri.toString()) + } catch (e: Exception) { + result.error( + "CREATE_FAILED", + "Error creating directory: ${e.message}", + null + ) + } + } + } +} + + +/** + * Gets the initial directory URI based on the specified shared storage location. + * + * @param location The shared storage location. + * @return The URI of the corresponding directory. + */ +private fun getInitialDirectoryUri(location: SharedStorage): Uri { + return when (location) { + SharedStorage.downloads -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Downloads.EXTERNAL_CONTENT_URI + } else { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .toUri() + } + } + + SharedStorage.images -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "content://com.android.externalstorage.documents/tree/primary%3APictures".toUri() + } else { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + .toUri() + } + } + + SharedStorage.video -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "content://com.android.externalstorage.documents/tree/primary%3AMovies".toUri() + } else { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES) + .toUri() + } + } + + SharedStorage.audio -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "content://com.android.externalstorage.documents/tree/primary%3AMusic".toUri() + } else { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC) + .toUri() + } + } + + SharedStorage.external -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "content://com.android.externalstorage.documents/tree/secondary%3A".toUri() + } else { + Environment.getExternalStorageDirectory().toUri() + } + } + + SharedStorage.files -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "content://com.android.externalstorage.documents/tree/primary%3A".toUri() + } else { + Environment.getDataDirectory().toUri() + } + } + } +} diff --git a/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/WiFi.kt b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/WiFi.kt new file mode 100644 index 0000000..07b39f1 --- /dev/null +++ b/third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/WiFi.kt @@ -0,0 +1,179 @@ +package com.bbflight.background_downloader + +import android.content.Context +import android.net.NetworkCapabilities +import android.os.Build +import android.util.Log +import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Processes WiFi requirement changes + * + * requireWiFiQueue: + * Each item is a [RequireWiFiChange] data object, and each workInfo + * will be either rescheduled (if enqueued) or paused and resumed (if running and + * possible) or cancelled and re-enqueued (if running and pause not possible + * + * reEnqueueQueue: + * Each item is a [ReEnqueue] data object represent one task re-enqueue. The task + * will be re-enqueued with an appropriate delay + */ +object WiFi { + private val scope = CoroutineScope(Dispatchers.Default) + + private val requireWiFiQueue = + Channel(capacity = Channel.UNLIMITED) + private val reEnqueueQueue = Channel Unit>(capacity = Channel.UNLIMITED) + + // completed once the current change's re-enqueues are enqueued + @Volatile + private var batchEnd: CompletableDeferred? = null + + init { + scope.launch { + for (change in requireWiFiQueue) { + val thisBatchEnd = CompletableDeferred() + batchEnd = thisBatchEnd + try { + // wait for the re-enqueues before the next change, with a timeout + if (change.execute() && withTimeoutOrNull(15000) { thisBatchEnd.await() } == null) { + val clearedTaskIds = synchronized(BDPlugin.tasksToReEnqueue) { + BDPlugin.tasksToReEnqueue.map { it.taskId } + .also { BDPlugin.tasksToReEnqueue.clear() } + } + Log.w( + BDPlugin.TAG, + "WiFi re-enqueue watchdog timeout, cleared taskIds $clearedTaskIds" + ) + } + } catch (e: Exception) { + Log.e(BDPlugin.TAG, "Exception during requireWiFiChange execution: $e") + } + } + } + + scope.launch { + for (reEnqueue in reEnqueueQueue) { + try { + reEnqueue() + } catch (e: Exception) { + Log.e(BDPlugin.TAG, "Exception during WiFi reEnqueue: $e") + } + } + } + } + + /** + * Execute the requireWiFi change request + */ + suspend fun requireWiFiChange(change: RequireWiFiChange) { + requireWiFiQueue.send(change) + } + + /** + * Re-enqueue this task and associated data. Null signals end of batch + */ + suspend fun reEnqueue(enqueueItem: EnqueueItem?) { + if (enqueueItem != null) { + reEnqueueQueue.send { enqueueItem.reEnqueue() } + } else { + // captured now, so a late signal cannot end a later batch + val thisBatchEnd = batchEnd + reEnqueueQueue.send { thisBatchEnd?.complete(Unit) } + } + } +} + +/** + * Change the global WiFi requirement setting + */ +class RequireWiFiChange( + private val applicationContext: Context, + private val requireWifi: RequireWiFi, + private val rescheduleRunningTasks: Boolean, + private val alsoRestartUploads: Boolean +) { + /** + * Execute the change in WiFi requirement and return + * true if one or more tasks have been scheduled for re-enqueue + */ + suspend fun execute(): Boolean { + BDPlugin.requireWifi = requireWifi + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + prefs.edit().apply { + putInt(BDPlugin.keyRequireWiFi, requireWifi.ordinal) + apply() + } + val tasksMap = getTaskMap(prefs) + val workManager = WorkManager.getInstance(applicationContext) + val workInfos = workManager.getWorkInfosByTag(BDPlugin.TAG).get() + .filter { !it.state.isFinished } + // collected first and all added to tasksToReEnqueue before any is canceled or paused, + // so the set can't run empty, which ends the batch, before the last task is added + val tasksToCancel = mutableListOf() + val tasksToPause = mutableListOf() + for (workInfo in workInfos) { + val tags = workInfo.tags.filter { it.contains("taskId=") } + if (tags.isNotEmpty()) { + val taskId = tags.first().substring(7) + val task = tasksMap[taskId] + if (task != null && (task.isDownloadTask() || task.isUploadTask())) { + // compare with the constraints the work was enqueued with, which WorkManager + // keeps across app restarts, so work enqueued by an earlier process is included + if (BDPlugin.taskRequiresWifi(task) != workInfo.constraints.requiresWifi()) { + when (workInfo.state) { + WorkInfo.State.ENQUEUED -> tasksToCancel.add(task) + + WorkInfo.State.RUNNING -> { + if (rescheduleRunningTasks && task.isDownloadTask()) { + tasksToPause.add(task) + } else if (alsoRestartUploads && task.isUploadTask()) { + tasksToCancel.add(task) + } + } + + else -> {} + } + } + } + } + } + BDPlugin.tasksToReEnqueue.addAll(tasksToCancel + tasksToPause) + tasksToPause.forEach { BDPlugin.pauseTaskWithId(it.taskId) } + for (task in tasksToCancel) { + // the cancel's update is intercepted within cancelActiveTaskWithId, so an entry still + // there means it sent none: the task finished before it was added, and its entry + // would otherwise stay until the timeout + if (!BDPlugin.cancelActiveTaskWithId(applicationContext, task.taskId, workManager) || + BDPlugin.tasksToReEnqueue.contains(task) + ) { + BDPlugin.tasksToReEnqueue.remove(task) + if (BDPlugin.tasksToReEnqueue.isEmpty()) { + WiFi.reEnqueue(null) // nothing left to re-enqueue, so end the batch + } + } + } + return tasksToCancel.isNotEmpty() || tasksToPause.isNotEmpty() + } + + /** + * Return true if these [Constraints] restrict the work to WiFi, as set in [BDPlugin.doEnqueue] + * + * From API 28 the WiFi requirement is a NetworkRequest, and WorkManager then stores + * NetworkType.NOT_REQUIRED as the network type. Before API 28 it is NetworkType.UNMETERED + */ + private fun Constraints.requiresWifi(): Boolean = + (Build.VERSION.SDK_INT >= 28 && + requiredNetworkRequest?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) || + requiredNetworkType == NetworkType.UNMETERED +} \ No newline at end of file diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_cancel_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_cancel_24.xml new file mode 100644 index 0000000..9cebae5 --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_cancel_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_download_done_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_download_done_24.xml new file mode 100644 index 0000000..3341ed2 --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_download_done_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_error_outline_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_error_outline_24.xml new file mode 100644 index 0000000..bbb43bd --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_error_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_file_download_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_file_download_24.xml new file mode 100644 index 0000000..a4ab511 --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_file_download_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_file_upload_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_file_upload_24.xml new file mode 100644 index 0000000..35253ee --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_file_upload_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_pause_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_pause_24.xml new file mode 100644 index 0000000..938bd7f --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_pause_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/drawable/outline_play_arrow_24.xml b/third_party/background_downloader/android/src/main/res/drawable/outline_play_arrow_24.xml new file mode 100644 index 0000000..38254ec --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/drawable/outline_play_arrow_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/third_party/background_downloader/android/src/main/res/values/strings.xml b/third_party/background_downloader/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..dcc7b88 --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Cancel + Pause + Resume + Downloads + Updates on downloads and + uploads in progress + \ No newline at end of file diff --git a/third_party/background_downloader/android/src/main/res/xml/bgd_file_paths.xml b/third_party/background_downloader/android/src/main/res/xml/bgd_file_paths.xml new file mode 100644 index 0000000..f6bcf7d --- /dev/null +++ b/third_party/background_downloader/android/src/main/res/xml/bgd_file_paths.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/AuthTest.kt b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/AuthTest.kt new file mode 100644 index 0000000..1761eb1 --- /dev/null +++ b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/AuthTest.kt @@ -0,0 +1,109 @@ +package com.bbflight.background_downloader + +import org.junit.Test +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue + +class AuthTest { + + private fun createAuth( + accessToken: String? = null, + accessHeaders: Map = emptyMap(), + accessQueryParams: Map = emptyMap(), + accessTokenExpiryTime: Long? = null, + refreshToken: String? = null, + refreshHeaders: Map = emptyMap(), + refreshQueryParams: Map = emptyMap(), + refreshUrl: String? = null, + onAuthRawHandle: Long? = null + ): Auth = Auth( + accessToken = accessToken, + accessHeaders = accessHeaders, + accessQueryParams = accessQueryParams, + accessTokenExpiryTime = accessTokenExpiryTime, + refreshToken = refreshToken, + refreshHeaders = refreshHeaders, + refreshQueryParams = refreshQueryParams, + refreshUrl = refreshUrl, + onAuthRawHandle = onAuthRawHandle + ) + + @Test + fun `getExpandedAccessHeaders replaces tokens correctly`() { + val auth = createAuth( + accessToken = "access123", + refreshToken = "refresh123", + accessHeaders = mapOf("Authorization" to "Bearer {accessToken}", "Refresh" to "{refreshToken}") + ) + val expandedHeaders = auth.getExpandedAccessHeaders() + assertEquals("Bearer access123", expandedHeaders["Authorization"]) + assertEquals("refresh123", expandedHeaders["Refresh"]) + } + + @Test + fun `getExpandedAccessQueryParams replaces tokens correctly`() { + val auth = createAuth( + accessToken = "access456", + refreshToken = "refresh456", + accessQueryParams = mapOf("token" to "A {accessToken}", "refresh" to "A {refreshToken}") + ) + val expandedParams = auth.getExpandedAccessQueryParams() + assertEquals("A access456", expandedParams["token"]) + assertEquals("A refresh456", expandedParams["refresh"]) + } + + @Test + fun `addOrUpdateQueryParams adds new query params to URL`() { + // ignored because we would have to mock Uri.parse +// val auth = createAuth() +// val url = "https://example.com/api" +// val queryParams = mapOf("param1" to "value1", "param2" to "value2") +// val updatedUri = auth.addOrUpdateQueryParams(url, queryParams) +// assertEquals("https://example.com/api?param1=value1¶m2=value2", updatedUri.toString()) + } + + @Test + fun `addOrUpdateQueryParams updates existing query params`() { + // ignored because we would have to mock Uri.parse +// val auth = createAuth() +// val url = "https://example.com/api?param1=oldValue" +// val queryParams = mapOf("param1" to "newValue") +// val updatedUri = auth.addOrUpdateQueryParams(url, queryParams) +// assertEquals("https://example.com/api?param1=newValue", updatedUri.toString()) + } + + @Test + fun `isTokenExpired returns true when token is expired`() { + val expiryTime = System.currentTimeMillis() - 10000 // Expired 10 seconds ago + val auth = createAuth(accessTokenExpiryTime = expiryTime) + assertTrue(auth.isTokenExpired()) + } + + @Test + fun `isTokenExpired returns false when token is not expired`() { + val expiryTime = System.currentTimeMillis() + 60000 // Expires in 1 minute + val auth = createAuth(accessTokenExpiryTime = expiryTime) + assertFalse(auth.isTokenExpired()) + } + + @Test + fun `isTokenExpired applies buffer time correctly`() { + val expiryTime = System.currentTimeMillis() + 5000 // Expires in 5 seconds + val auth = createAuth(accessTokenExpiryTime = expiryTime) + assertTrue(auth.isTokenExpired(bufferTime = 7000)) // Buffer time makes it appear expired + assertFalse(auth.isTokenExpired(bufferTime = 3000)) // Within buffer, not expired + } + + @Test + fun `hasOnAuthCallback returns true when onAuthRawHandle is set`() { + val auth = createAuth(onAuthRawHandle = 1234L) + assertTrue(auth.hasOnAuthCallback()) + } + + @Test + fun `hasOnAuthCallback returns false when onAuthRawHandle is not set`() { + val auth = createAuth(onAuthRawHandle = null) + assertFalse(auth.hasOnAuthCallback()) + } +} diff --git a/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/HelpersTest.kt b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/HelpersTest.kt new file mode 100644 index 0000000..3f98b59 --- /dev/null +++ b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/HelpersTest.kt @@ -0,0 +1,35 @@ +package com.bbflight.background_downloader + +//val urlWithContentLength = "https://storage.googleapis.com/approachcharts/test/5MB-test.ZIP" +//var task = Task(mapOf("url" to urlWithContentLength, "filename" to "taskFilename.txt")) +// +// +//class HelpersTest { +// +// @Test +// fun parseRange() { +// assertEquals(Pair(10L, 20L), parseRange("bytes=10-20")) +// assertEquals(Pair(0L, 20L), parseRange("bytes=-20")) +// assertEquals(Pair(10L, null), parseRange("bytes=10-")) +// assertEquals(Pair(0L, null), parseRange("")) +// } +// +// @Test +// fun getContentLength() { +// var h = mapOf>() +// assertEquals(-1, getContentLength(h, task)) +// h = mapOf("Content-Length" to listOf("123")) +// assertEquals(123, getContentLength(h, task)) +// h = mapOf("content-length" to listOf("123")) +// assertEquals(123, getContentLength(h, task)) +// task = task.copyWith(headers = mapOf("Range" to "bytes=0-20")) +// h = mapOf() +// assertEquals(21, getContentLength(h, task)) +// task = task.copyWith(headers = mapOf("Known-Content-Length" to "456")) +// assertEquals(456, getContentLength(h, task)) +// task = task.copyWith(headers = mapOf("Known-Content-Length" to "456", "Range" to "bytes=0-20")) +// assertEquals(21, getContentLength(h, task)) +// h = mapOf("Content-Length" to listOf("123")) +// assertEquals(123, getContentLength(h, task)) +// } +//} diff --git a/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/ReRunDecisionTest.kt b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/ReRunDecisionTest.kt new file mode 100644 index 0000000..fb8428c --- /dev/null +++ b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/ReRunDecisionTest.kt @@ -0,0 +1,33 @@ +package com.bbflight.background_downloader + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReRunDecisionTest { + + @Test + fun firstRunRuns() { + assertEquals(ReRunDecision.run, reRunDecision(0, true, emptyList())) + // also when its task left the tasks map, or another work exists + assertEquals(ReRunDecision.run, reRunDecision(0, false, emptyList())) + assertEquals(ReRunDecision.run, reRunDecision(0, true, listOf("other"))) + } + + @Test + fun reRunOfUnfinishedTaskRuns() { + assertEquals(ReRunDecision.run, reRunDecision(1, true, emptyList())) + assertEquals(ReRunDecision.run, reRunDecision(3, true, emptyList())) + } + + @Test + fun reRunOfFinishedTaskIsSkipped() { + assertEquals(ReRunDecision.skipFinished, reRunDecision(1, false, emptyList())) + } + + @Test + fun reRunWithAnotherUnfinishedWorkIsSkipped() { + assertEquals(ReRunDecision.skipSuperseded, reRunDecision(1, true, listOf("other"))) + // the other work holds the slot, so superseded wins over finished + assertEquals(ReRunDecision.skipSuperseded, reRunDecision(2, false, listOf("other"))) + } +} diff --git a/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/SerializationTest.kt b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/SerializationTest.kt new file mode 100644 index 0000000..0ba07ad --- /dev/null +++ b/third_party/background_downloader/android/src/test/kotlin/com/bbflight/background_downloader/SerializationTest.kt @@ -0,0 +1,32 @@ +package com.bbflight.background_downloader + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +@Serializable +data class Holder(val dir: BaseDirectory) + + +class SerializationTest { + + @Test + fun encodeEnum() { + var enc = bdJson.encodeToString(Holder(BaseDirectory.applicationDocuments)) + assertEquals("{\"dir\":0}", enc) + enc = bdJson.encodeToString(Holder(BaseDirectory.applicationLibrary)) + assertEquals("{\"dir\":3}", enc) + val dec = bdJson.decodeFromString(enc) + assertEquals(BaseDirectory.applicationLibrary, dec.dir) + } + + @Test + fun ignoreUnknownKeysTest() { + val jsonWithUnknownKeys = "{\"dir\":0,\"unknownField\":123,\"anotherUnknown\":\"xyz\"}" + val dec = bdJson.decodeFromString(jsonWithUnknownKeys) + assertEquals(BaseDirectory.applicationDocuments, dec.dir) + } + +} diff --git a/third_party/background_downloader/ios/background_downloader.podspec b/third_party/background_downloader/ios/background_downloader.podspec new file mode 100644 index 0000000..6c6fc9b --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint background_downloader.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'background_downloader' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'background_downloader/Sources/background_downloader/**/*.swift' + s.dependency 'Flutter' + s.platform = :ios, '14.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + s.resource_bundles = {'background_downloader_privacy' => ['background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy']} +end diff --git a/third_party/background_downloader/ios/background_downloader/Package.swift b/third_party/background_downloader/ios/background_downloader/Package.swift new file mode 100644 index 0000000..df3bdf8 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import Foundation +import PackageDescription + +var swiftSettings: [SwiftSetting] = [] +if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_NOTIFICATIONS"] == "1" { + swiftSettings.append(.define("BYPASS_PERMISSION_NOTIFICATIONS")) +} +if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"] == "1" { + swiftSettings.append(.define("BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY")) +} +if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"] == "1" { + swiftSettings.append(.define("BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY")) +} + +let package = Package( + name: "background_downloader", + platforms: [ + .iOS("14.0") + ], + products: [ + .library(name: "background-downloader", targets: ["background_downloader"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "background_downloader", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ], + resources: [ + .process("PrivacyInfo.xcprivacy"), + ], + swiftSettings: swiftSettings.isEmpty ? nil : swiftSettings + ) + ] +) \ No newline at end of file diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Auth.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Auth.swift new file mode 100644 index 0000000..3a84885 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Auth.swift @@ -0,0 +1,92 @@ +// +// Auth.swift +// +// Created by Bram on 11/8/24. +// + +import Foundation + +struct Auth: Codable, Hashable { + var accessToken: String? + var accessHeaders: [String: String] = [:] + var accessQueryParams: [String: String] = [:] + var accessTokenExpiryTime: Int64? + var refreshToken: String? + var refreshHeaders: [String: String] = [:] + var refreshQueryParams: [String: String] = [:] + var refreshUrl: String? + var onAuthRawHandle: Int64? + + /** + * Returns the headers specified for the access request, with the + * template `{accessToken}` and `{refreshToken}` replaced. + */ + func getExpandedAccessHeaders() -> [String: String] { + return expandMap(accessHeaders) + } + + /** + * Returns the query parameters specified for the access request, with the + * template `{accessToken}` and `{refreshToken}` replaced. + */ + func getExpandedAccessQueryParams() -> [String: String] { + return expandMap(accessQueryParams) + } + + /// Add/update the query parameters in this `url` with `queryParams` + /// and return the new URI. + func addOrUpdateQueryParams( + url: String, + queryParams: [String: String] = [:] + ) -> URL { + guard let startUri = URL(string: url) else { + // Handle invalid URL gracefully, e.g., return the original URL or throw an error + return URL(string: url)! + } + guard !queryParams.isEmpty else { return startUri } + + var components = URLComponents(url: startUri, resolvingAgainstBaseURL: false)! + var updatedQueryParams = components.queryItems?.reduce(into: [String: String]()) { (result, item) in + result[item.name] = item.value + } ?? [:] + + for (key, value) in queryParams { + updatedQueryParams[key] = value + } + + components.queryItems = updatedQueryParams.map { URLQueryItem(name: $0.key, value: $0.value) } + return components.url! + } + + /// Returns true if the `accessTokenExpiryTime` is after now plus + /// the `bufferTime`, otherwise returns false. + func isTokenExpired(bufferTime: Int64 = 10000) -> Bool { + guard let expiry = accessTokenExpiryTime else { return false } + let expiryTimeWithBuffer = Int64(Date().timeIntervalSince1970 * 1000) + bufferTime + return expiryTimeWithBuffer > expiry + } + + /// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken} + /// + /// Returns the expanded map, without changing the original + private func expandMap(_ mapToExpand: [String: String]) -> [String: String] { + var newMap: [String: String] = [:] + for (key, value) in mapToExpand { + var newValue = value + if let accessToken = accessToken { + newValue = newValue.replacingOccurrences(of: "{accessToken}", with: accessToken) + } + if let refreshToken = refreshToken { + newValue = newValue.replacingOccurrences(of: "{refreshToken}", with: refreshToken) + } + newMap[key] = newValue + } + return newMap + } + + /// True if this `Auth` object has an 'onAuth' callback that can be called to refresh the + /// access token. + func hasOnAuthCallback() -> Bool { + return onAuthRawHandle != nil + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/BDPlugin.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/BDPlugin.swift new file mode 100644 index 0000000..4177448 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/BDPlugin.swift @@ -0,0 +1,1044 @@ +import Flutter +import UIKit +import BackgroundTasks +import os.log +import MobileCoreServices + +let log = OSLog.init(subsystem: "BackgroundDownloader", category: "Downloader") + +/// Main Downloader plugin object, handles incoming methodCalls +public class BDPlugin: NSObject, FlutterPlugin, UNUserNotificationCenterDelegate { + + static let instance = BDPlugin() + + public static var defaultResourceTimeout = 4 * 60 * 60.0 // in seconds + public static var defaultRequestTimeout = 60.0 // in seconds + public static var keyResumeDataMap = "com.bbflight.background_downloader.resumeDataMap.v2" + public static var keyStatusUpdateMap = "com.bbflight.background_downloader.statusUpdateMap.v2" + public static var keyProgressUpdateMap = "com.bbflight.background_downloader.progressUpdateMap.v2" + public static var keyConfigLocalize = "com.bbflight.background_downloader.config.localize" + public static var keyConfigResourceTimeout = "com.bbflight.background_downloader.config.resourceTimeout" + public static var keyConfigRequestTimeout = "com.bbflight.background_downloader.config.requestTimeout" + public static var keyConfigProxyAdress = "com.bbflight.background_downloader.config.proxyAddress" + public static var keyConfigProxyPort = "com.bbflight.background_downloader.config.proxyPort" + public static var keyConfigCheckAvailableSpace = "com.bbflight.background_downloader.config.checkAvailableSpace" + public static var keyConfigExcludeFromCloudBackup = "com.bbflight.background_downloader.config.excludeFromCloudBackup" + public static var keyConfigSkipExistingFiles = "com.bbflight.background_downloader.config.skipExistingFiles" + public static var keyRequireWiFi = "com.bbflight.background_downloader.requireWiFi" + public static var forceFailPostOnBackgroundChannel = false + + static var progressInfo = [String: (lastProgressUpdateTime: TimeInterval, + lastProgressValue: Double, + lastTotalBytesDone: Int64, + lastNetworkSpeed: Double)]() // upadtetime, progress %, bytes, speed + static var initialResponseDataProcessed = Set() // by taskId + static var uploaderForUrlSessionTaskIdentifier = [Int:Uploader]() // maps from UrlSessionTask TaskIdentifier + static var haveregisteredNotificationCategories = false + static var requireWiFi = RequireWiFi.asSetByTask // global setting + static var taskIdsThatCanResume = Set() // taskIds that can resume + static var taskIdsProgrammaticallyCanceledBeforeStart = Set() // skips completion processing for these tasks + static var taskIdsProgrammaticallyCanceledAfterStart = Set() // skips error handling for these tasks + static var tasksToReEnqueue = Set() // for when WiFi requirement changes + static var notificationConfigJsonStrings = [String:String]() // by taskId + static var localResumeData = [String : String]() // locally stored to enable notification resume + static var remainingBytesToDownload = [String : Int64]() // keyed by taskId + static var responseBodyData = [String: [Data]]() // list of Data objects received for this UploadTask id + static var tasksWithModifications = [String : Task]() // [taskId : Task with suggested filename] + static var tasksWithContentLengthOverride = [String : Int64]() // [taskId : Content length] + static var tasksWithTempUploadFile = [String : URL]() // [taskId : file URL] + static var mimeTypes = [String : String]() // [taskId : mimeType] + static var charSets = [String : String]() // [taskId : charSet] + static var holdingQueue: HoldingQueue? = nil + + static var propertyLock: NSLock = NSLock() // used to synchronize access to static properties + + public static var backgroundChannel: FlutterMethodChannel? // for native <-> plugin comms + public static var callbackChannel: FlutterMethodChannel? // for native to trigger task callbacks + public static var flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback? + + /// Optional closure called on task status changes. + /// Set in native iOS code (e.g. AppDelegate) for advanced use cases like Live Activities or Widgets. + public static var onNativeTaskStatusChange: ((Task, TaskStatusUpdate) -> Void)? + + /// Optional closure called on task progress updates. + /// Set in native iOS code (e.g. AppDelegate) for advanced use cases like Live Activity progress bars. + public static var onNativeTaskProgressChange: ((Task, Double) -> Void)? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "com.bbflight.background_downloader", binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: channel) + let callbackChannel = FlutterMethodChannel(name: "com.bbflight.background_downloader.callbacks", binaryMessenger: registrar.messenger()) + registrar.addApplicationDelegate(instance) + if (backgroundChannel == nil) { + // Only register the background channel for the primary isolate + backgroundChannel = FlutterMethodChannel(name: "com.bbflight.background_downloader.background", binaryMessenger: registrar.messenger()) + BDPlugin.callbackChannel = callbackChannel + } + UriUtilsMethodCallHelper.register(with: registrar) // not a real plugin, but has a methodCallHandler + requireWiFi = RequireWiFi(rawValue: UserDefaults.standard.integer(forKey: BDPlugin.keyRequireWiFi))! + } + + @objc + public static func setPluginRegistrantCallback(_ callback: @escaping FlutterPluginRegistrantCallback) { + flutterPluginRegistrantCallback = callback + } + + + /// Handler for Flutter plugin method channel calls + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + _Concurrency.Task { @MainActor () -> Void in + // to allow async/await + switch call.method { + case "reset": + await methodReset(call: call, result: result) + case "enqueue": + await methodEnqueue(call: call, result: result) + case "enqueueAll": + await methodEnqueueAll(call: call, result: result) + case "allTasks": + await methodAllTasks(call: call, result: result) + case "cancelTasksWithIds": + await methodCancelTasksWithIds(call: call, result: result) + case "taskForId": + await methodTaskForId(call: call, result: result) + case "pause": + await methodPause(call: call, result: result) + case "pauseAll": + await methodPauseAll(call: call, result: result) + case "updateNotification": + methodUpdateNotification(call: call, result: result) + case "moveToSharedStorage": + await methodMoveToSharedStorage(call: call, result: result) + case "pathInSharedStorage": + await methodPathInSharedStorage(call: call, result: result) + case "openFile": + methodOpenFile(call: call, result: result) + case "requireWiFi": + methodRequireWiFi(call: call, result: result) + case "getRequireWiFiSetting": + methodGetRequireWiFiSetting(result: result) + /// ParallelDownloadTask child updates + case "chunkStatusUpdate": + methodUpdateChunkStatus(call: call, result: result) + case "chunkProgressUpdate": + methodUpdateChunkProgress(call: call, result: result) + /// internal use + case "popResumeData": + methodPopResumeData(result: result) + case "popStatusUpdates": + methodPopStatusUpdates(result: result) + case "popProgressUpdates": + methodPopProgressUpdates(result: result) + /// Permissions + case "permissionStatus": + await methodPermissionStatus(call: call, result: result) + case "requestPermission": + await methodRequestPermission(call: call, result: result) + /// configuration + case "configLocalize": + storeInUserDefaults(key: BDPlugin.keyConfigLocalize, value: call.arguments, result: result) + case "configResourceTimeout": + storeInUserDefaults(key: BDPlugin.keyConfigResourceTimeout, value: call.arguments, result: result) + case "configRequestTimeout": + storeInUserDefaults(key: BDPlugin.keyConfigRequestTimeout, value: call.arguments, result: result) + case "configProxyAddress": + storeInUserDefaults(key: BDPlugin.keyConfigProxyAdress, value: call.arguments, result: result) + case "configProxyPort": + storeInUserDefaults(key: BDPlugin.keyConfigProxyPort, value: call.arguments, result: result) + case "configCheckAvailableSpace": + storeInUserDefaults(key: BDPlugin.keyConfigCheckAvailableSpace, value: call.arguments, result: result) + case "configHoldingQueue": + methodConfigHoldingQueue(call: call, result: result) + case "configExcludeFromCloudBackup": + storeInUserDefaults(key: BDPlugin.keyConfigExcludeFromCloudBackup, value: call.arguments, result: result) + case "configSkipExistingFiles": + storeInUserDefaults(key: BDPlugin.keyConfigSkipExistingFiles, value: call.arguments as? Int, result: result) + case "platformVersion": + result(UIDevice.current.systemVersion) + case "forceFailPostOnBackgroundChannel": + methodForceFailPostOnBackgroundChannel(call: call, result: result) + case "testSuggestedFilename": + methodTestSuggestedFilename(call: call, result: result) + default: + os_log("Invalid method: %@", log: log, type: .error, call.method) + result(FlutterMethodNotImplemented) + } + } + } + + /// Enqueues one task + /// + /// Returns true if successful + private func methodEnqueue(call: FlutterMethodCall, result: @escaping FlutterResult) async { + let args = call.arguments as! [Any] + let taskJsonString = args[0] as! String + let notificationConfigJsonString = args[1] as? String + let isResume = args.count == 5 + let resumeDataAsBase64String = isResume + ? args[2] as? String ?? "" + : "" + if BDPlugin.holdingQueue == nil { + postResult(result: result, value: await doEnqueue(taskJsonString: taskJsonString, notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: resumeDataAsBase64String)) + } else { + // add entry for HoldingQueue, after checks + guard let task = taskFrom(jsonString: taskJsonString) + else { + os_log("Could not decode %@ to Task", log: log, taskJsonString) + postResult(result: result, value: false) + return + } + guard validateUrl(task) != nil else + { + os_log("Invalid url: %@", log: log, type: .info, task.url) + postResult(result: result, value: false) + return + } + os_log("Enqueueing task with id %@ to the HoldingQueue", log: log, type: .info, task.taskId) + await BDPlugin.holdingQueue?.add(item: EnqueueItem(task: task, notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: resumeDataAsBase64String)) + processStatusUpdate(task: task, status: .enqueued) + postResult(result: result, value: true) + } + } + + /// Enqueues a list of tasks + /// + /// Returns a list of equal length of booleans indicating whether each individual enqueue succeeded + private func methodEnqueueAll(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let taskListJsonString = args[0] as? String, + let notificationConfigListJsonString = args[1] as? String, + let tasks = try? JSONDecoder().decode([Task].self, from: taskListJsonString.data(using: .utf8)!), + let notificationConfigs = try? JSONDecoder().decode([NotificationConfig?].self, from: notificationConfigListJsonString.data(using: .utf8)!) else { + os_log("Invalid arguments to enqueueAll: %@", log: log, String(describing: call.arguments)) + postResult(result: result, value: []) + return + } + _Concurrency.Task.detached { // Run the loop off the main thread + var results: [Bool] = [] + for (index, task) in tasks.enumerated() { + let notificationConfig = notificationConfigs.indices.contains(index) ? notificationConfigs[index] : nil + let notificationConfigJsonString = notificationConfig != nil ? try? String(data: JSONEncoder().encode(notificationConfig), encoding: .utf8) : nil + guard let taskJsonString = jsonStringFor(task: task) else { + os_log("Failed to serialize taskId %@", log: log, task.taskId) + results.append(false) + continue + } + if BDPlugin.holdingQueue == nil { + // Enqueue directly using doEnqueue + let success = await self.doEnqueue(taskJsonString: taskJsonString, notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: "") + results.append(success) + } else { + // Add to holding queue + guard validateUrl(task) != nil else { + os_log("Invalid url: %@", log: log, type: .info, task.url) + results.append(false) + continue + } + os_log("Enqueueing task with id %@ to the HoldingQueue", log: log, type: .info, task.taskId) + await BDPlugin.holdingQueue?.add(item: EnqueueItem(task: task, notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: "")) + processStatusUpdate(task: task, status: .enqueued) + results.append(true) + } + } + let finalResults = results + await MainActor.run { + postResult(result: result, value: finalResults) + } + } + } + + + /// Do the actual enqueue as a URLSessionTask + public func doEnqueue(taskJsonString: String, notificationConfigJsonString: String?, resumeDataAsBase64String: String) async -> Bool { + let taskDescription = notificationConfigJsonString == nil ? taskJsonString : taskJsonString + separatorString + notificationConfigJsonString! + var isResume = !resumeDataAsBase64String.isEmpty + let resumeData = isResume ? Data(base64Encoded: resumeDataAsBase64String) : nil + guard let task = taskFrom(jsonString: taskJsonString) + else { + os_log("Could not decode %@ to Task", log: log, taskJsonString) + return false + } + // Check if the file should be skipped (only for downloads, as an + // upload's file always exists) + if !isResume && isDownloadTask(task: task) { + let skipThreshold = UserDefaults.standard.object(forKey: BDPlugin.keyConfigSkipExistingFiles) as? Int ?? -1 + // (not possible if the filename will be suggested by the server) + if skipThreshold != -1 && task.filename != "?" { + let filePath = getFilePath(for: task) + if let path = filePath, FileManager.default.fileExists(atPath: path) { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + if let fileSize = attributes[.size] as? Int64 { + // threshold 0 skips any existing file, including an empty one + if skipThreshold == 0 || fileSize > skipThreshold * 1024 * 1024 { + processStatusUpdate(task: task, status: .complete, responseStatusCode: 304) + return true + } + } + } catch { + // Ignore + } + } + } + } + + if notificationConfigJsonString != nil { + BDPlugin.propertyLock.withLock { + BDPlugin.notificationConfigJsonStrings[task.taskId] = notificationConfigJsonString + } + } + isResume = isParallelDownloadTask(task: task) ? isResume : isResume && resumeData != nil + let verb = isResume ? "Enqueueing (to resume)" : "Enqueueing" + os_log("%@ task with id %@", log: log, type: .info, verb, task.taskId) + UrlSessionDelegate.createUrlSession() + guard let url = validateUrl(task) else + { + os_log("Invalid url: %@", log: log, type: .info, task.url) + return false + } + var baseRequest = URLRequest(url: url) + baseRequest.httpMethod = task.httpRequestMethod + for (key, value) in task.headers { + // For UploadTask, copy headers + // unless it's "Range" or "Content-Disposition" (case-insensitive). + // For other task types, copy all headers. + if !isUploadTask(task: task) || + (key.lowercased() != "range" && key.lowercased() != "content-disposition") { + baseRequest.setValue(value, forHTTPHeaderField: key) + } + } + let requiresWiFi = taskRequiresWiFi(task: task) + if requiresWiFi { + baseRequest.allowsCellularAccess = false + } + if isParallelDownloadTask(task: task) { + // ParallelDownloadTask itself is not part of a urlSession, so handled separately + baseRequest.httpMethod = "HEAD" // override + return await scheduleParallelDownload(task: task, taskDescription: taskDescription, baseRequest: baseRequest, resumeData: resumeDataAsBase64String) + } else if isDownloadTask(task: task) || isDataTask(task: task) + { + return await scheduleDownload(task: task, taskDescription: taskDescription, baseRequest: baseRequest, resumeData: resumeData, notificationConfigJsonString: notificationConfigJsonString) + } else + { + return await scheduleUpload(task: task, taskDescription: taskDescription, baseRequest: baseRequest, notificationConfigJsonString: notificationConfigJsonString) + } + } + + + /// Schedule a download task + private func scheduleDownload(task: Task, taskDescription: String, baseRequest: URLRequest, resumeData: Data?, notificationConfigJsonString: String?) async -> Bool { + var request = baseRequest + if task.post != nil { + // binary post holds one byte per character, so encode as Latin-1 + request.httpBody = (task.post ?? "").data(using: task.postIsBinary == true ? .isoLatin1 : .utf8) + } + let urlSessionDownloadTask = resumeData == nil ? UrlSessionDelegate.urlSession!.downloadTask(with: request) : UrlSessionDelegate.urlSession!.downloadTask(withResumeData: resumeData!) + urlSessionDownloadTask.taskDescription = taskDescription + urlSessionDownloadTask.priority = 1 - Float(task.priority) / 10 + urlSessionDownloadTask.resume() + await postEnqueuedStatusIfNotAlreadyDone(task: task, notificationConfigJsonString: notificationConfigJsonString) + return true + } + + /// Schedule an upload task + /// + /// For binary uploads, the mime-type will be set to [Task.mimeType] and the Content-Disposition header will be: + /// - set to 'attachment = "filename"' if the task.headers field does not contain + /// an entry for 'Content-Disposition' + /// - not set at all (i.e. omitted) if the task.headers field contains an entry + /// for 'Content-Disposition' with the value '' (an empty string) + /// - set to the value of task.headers['Content-Disposition'] in all other cases + private func scheduleUpload(task: Task, taskDescription: String, baseRequest: URLRequest, notificationConfigJsonString: String?) async -> Bool { + var request = baseRequest + if isBinaryUploadTask(task: task) { + // binary post can use uploadTask fromFile method + os_log("Binary file upload", log: log, type: .debug) + let fileUrl: URL + // filename field can contain filename or a file url + let unpackedFilename = unpack(packedString: task.filename) + var maybeFileUrl = unpackedFilename.uri + var filename = unpackedFilename.filename + if (maybeFileUrl == nil) { + // filePath mode + guard let directory = try? directoryForTask(task: task) else { + os_log("Could not find directory for taskId %@", log: log, type: .info, task.taskId) + return false + } + fileUrl = directory.appendingPath(task.filename) // and filename is already unpacked + } else { + // URI mode + maybeFileUrl = decodeToFileUrl(uri: maybeFileUrl!) + if maybeFileUrl == nil { + os_log("Could not convert uri to file url for taskId %@", log: log, type: .info, task.taskId) + return false + } + if maybeFileUrl!.scheme != "file" { + os_log("File uri must be file scheme %@", log: log, type: .info, task.taskId) + return false + } + if filename == nil { + // attemp to set a filename for the uploaded file in the task object + let derivedFilename = URL(fileURLWithPath: maybeFileUrl!.path).lastPathComponent + let newTask = task.copyWith(filename: pack(filename: derivedFilename, uri: maybeFileUrl!)) + storeModifiedTask(task: newTask) + filename = derivedFilename + } + fileUrl = maybeFileUrl! + } + if !FileManager.default.fileExists(atPath: fileUrl.path) { + os_log("Could not find file %@ for taskId %@", log: log, type: .info, fileUrl.path, task.taskId) + return false + } + let resolvedMimeType = task.mimeType?.isEmpty == true + ? getMimeType(fromFilename: fileUrl.path) + : task.mimeType ?? "application/octet-stream" + request.setValue(resolvedMimeType, forHTTPHeaderField: "Content-Type") + let taskContentDisposition = task.headers["Content-Disposition"] ?? task.headers["content-disposition"] + if (taskContentDisposition != "") { + if taskContentDisposition == nil { + if let encodedFilename = filename?.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + request.setValue("attachment; filename=\"\(encodedFilename)\"", forHTTPHeaderField: "Content-Disposition") + } else { + os_log("Could not encode task.fileName %@", log: log, type: .info, task.filename) + return false + } + } else { + request.setValue(taskContentDisposition!, forHTTPHeaderField: "Content-Disposition") + } + } + var uploadFileUrl = fileUrl // if no range given + if let rangeHeader = task.headers["Range"] { + // determine the start and content length from the range header + let regex = try? NSRegularExpression(pattern: #"bytes=(\d+)-(\d*)"#) + let range = NSRange(rangeHeader.startIndex.. Bool { + UrlSessionDelegate.createUrlSession() + BDPlugin.propertyLock.withLock({ + _ = BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.insert(taskId) + }) + + guard let urlSessionTask = await UrlSessionDelegate.getUrlSessionTaskWithId(taskId: taskId) as? URLSessionDownloadTask, + let task = await UrlSessionDelegate.getTaskWithId(taskId: taskId) + else { + // no regular task found, return if there's no ParalleldownloadTask either + BDPlugin.propertyLock.withLock({ + _ = BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(taskId) + }) + if let parallelDownloadTask = ParallelDownloader.downloads[taskId] { + if await parallelDownloadTask.pauseTask() { + os_log("Paused task with taskId %@", log: log, type: .info, taskId) + return true + } else { + os_log("Could not pause taskId %@", log: log, type: .info, taskId) + return false + } + } else { + os_log("Could not pause task %@, or task not found", log: log, type: .info, taskId) + return false + } + } + + guard let resumeData = await urlSessionTask.cancelByProducingResumeData() else { + os_log("Could not pause task %@ (cannot produce resume data)", log: log, type: .info, taskId) + processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .resume, description: "Task was paused but cannot resume")) + return false + } + + if processResumeData(task: task, resumeData: resumeData) { + processStatusUpdate(task: task, status: .paused) + os_log("Paused task with taskId %@", log: log, type: .info, taskId) + // update 'paused' notification if needed + if let notificationConfigJsonString = BDPlugin.notificationConfigJsonStrings[taskId], + let notificationConfig = notificationConfigFrom(jsonString: notificationConfigJsonString) + { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + updateNotification(task: task, notificationType: .paused, notificationConfig: notificationConfig) + } + } + BDPlugin.propertyLock.withLock({ + _ = BDPlugin.progressInfo.removeValue(forKey: taskId) // ensure .running update on resume + }) + return true + } else { + os_log("Could not post resume data for taskId %@: task paused but cannot be resumed", log: log, type: .info, taskId) + processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .resume, description: "Task was paused but cannot resume")) + return false + } + } + + + + /// Update the notification for this task + /// Args are: + /// - task + /// - notificationConfig - cannot be null + /// - taskStatus as ordinal in TaskStatus enum. If null, delete the notification + private func methodUpdateNotification(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as! [Any] + let taskJsonString = args[0] as! String + let notificationConfigJsonString = args[1] as! String + let taskStatusOrdinal = args[2] as? Int + guard let task = taskFrom(jsonString: taskJsonString), + let notificationConfig = notificationConfigFrom(jsonString: notificationConfigJsonString) + else { + os_log("Cannot decode Task or NotificationConfig", log: log) + return + } + if (taskStatusOrdinal == nil) { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [task.taskId]) + } else { + let notificationType = notificationTypeForTaskStatus(status: TaskStatus(rawValue: taskStatusOrdinal!)!) + updateNotification(task: task, notificationType: notificationType, notificationConfig: notificationConfig) + } + } + + + /// Returns a JSON String of a map of [ResumeData], keyed by taskId, that has been stored + /// in local shared preferences because they could not be delivered to the Dart side. + /// Local storage of this map is then cleared + private func methodPopResumeData(result: @escaping FlutterResult) { + popLocalStorage(key: BDPlugin.keyResumeDataMap, result: result) + } + + /// Returns a JSON String of a map of status updates, keyed by taskId, that has been stored + /// in local shared preferences because they could not be delivered to the Dart side. + /// Local storage of this map is then cleared + private func methodPopStatusUpdates(result: @escaping FlutterResult) { + popLocalStorage(key: BDPlugin.keyStatusUpdateMap, result: result) + } + + /// Returns a JSON String of a map of progress updates, keyed by taskId, that has been stored + /// in local shared preferences because they could not be delivered to the Dart side. + /// Local storage of this map is then cleared + private func methodPopProgressUpdates(result: @escaping FlutterResult) { + popLocalStorage(key: BDPlugin.keyProgressUpdateMap, result: result) + } + + /// Pops and returns locally stored map for this key as a JSON String, via the FlutterResult + private func popLocalStorage(key: String, result: @escaping FlutterResult) { + let defaults = UserDefaults.standard + guard let map = defaults.dictionary(forKey: key), + let jsonData = try? JSONSerialization.data(withJSONObject: map), + let jsonString = String(data: jsonData, encoding: .utf8) + else { + os_log("Could not pop local storage for key %@", log: log, type: .info, key) + result("{}") + return + } + defaults.removeObject(forKey: key) + result(jsonString) + return + } + + /// Moves a file represented by the first argument to a SharedStorage destination + /// + /// Results in the new filePath (or Uri, if asUriString is true) if successful, or nil + private func methodMoveToSharedStorage(call: FlutterMethodCall, result: @escaping FlutterResult) async { + let args = call.arguments as! [Any] + guard + let filePath = args[0] as? String, + let destination = SharedStorage.init(rawValue: args[1] as? Int ?? 0), + let directory = args[2] as? String, + let asUriString = args[4] as? Bool + else { + result(nil) + return + } + result(await moveToSharedStorage(filePathOrUriString: filePath, destination: destination, directory: directory, asUriString: asUriString)) + } + + /// Results path in SharedStorage (or Uri, if asUriString is true) if successful, or nil + private func methodPathInSharedStorage(call: FlutterMethodCall, result: @escaping FlutterResult) async { + let args = call.arguments as! [Any] + guard + let filePath = args[0] as? String, + let destination = SharedStorage(rawValue: args[1] as? Int ?? 0), + let directory = args[2] as? String, + let asUriString = args[3] as? Bool + else { + result(nil) + return + } + result(await pathInSharedStorage(filePath: filePath, destination: destination, directory: directory, asUriString: asUriString)) + } + + + /// Opens to file represented by the Task or filePath using iOS standard + /// + /// Results in true if successful + private func methodOpenFile(call: FlutterMethodCall, result: @escaping FlutterResult) { + var success = false + defer { + result(success) + } + let args = call.arguments as! [Any] + let taskJsonMapString = args[0] as? String + var filePath = args[1] as? String + if filePath == nil { + guard let task = taskFrom(jsonString: taskJsonMapString!) + else { + return + } + let unpacked = unpack(packedString: task.filename) + if let uri = unpacked.uri { + filePath = decodeToFileUrl(uri: uri)?.path + } else { + filePath = getFilePath(for: task) + } + } + if filePath == nil || !FileManager.default.fileExists(atPath: filePath!) { + os_log("File does not exist: %@", log: log, type: .info, filePath ?? "nil") + return + } + let mimeType = args[2] as? String + success = doOpenFile(filePath: filePath!, mimeType: mimeType) + } + + /** + * Set WiFi requirement globally, based on requirement. + * + * Affects future tasks and reschedules enqueued, inactive tasks + * with the new setting. + * Reschedules active tasks if rescheduleRunning is true, + * otherwise leaves those running with their prior setting + * + * - requirement is first argument (enum) + * - rescheduleRunning is second argument (bool) + * + * Returns true if successful + */ + private func methodRequireWiFi(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as! [Any] + guard let newRequireWiFi = RequireWiFi(rawValue: args[0] as? Int ?? 0) else { + result(false) + return + } + let rescheduleRunning = args[1] as? Bool ?? false + let alsoRestartUploads = args.count > 2 ? args[2] as? Bool ?? false : false + WiFiQueue.shared.requireWiFiChange(requireWiFi: newRequireWiFi, rescheduleRunningTasks: rescheduleRunning, alsoRestartUploads: alsoRestartUploads) + result(true) + } + + /// Returns current globval setting for 'RequireWiFi' as an ordinal / rawValue + private func methodGetRequireWiFiSetting(result: @escaping FlutterResult) { + let defaults = UserDefaults.standard + result(defaults.integer(forKey: BDPlugin.keyRequireWiFi)) + } + + /// Update the status of one chunk (part of a ParallelDownloadTask), and returns + /// the status of the parent task based on the 'sum' of its children, or null + /// if unchanged + /// + /// Arguments are the parent TaskId, chunk taskId, taskStatusOrdinal, exceptionJsonString, responseBody + private func methodUpdateChunkStatus(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as! [Any] + guard + let taskId = args[0] as? String, + let chunkTaskId = args[1] as? String, + let statusRawvalue = args[2] as? Int, + let parallelDownloadTask = ParallelDownloader.downloads[taskId] + else { + os_log("Could not process chunkStatusUpdate", log: log, type: .info) + result(nil) + return + } + let exceptionJson = args[3] as? String + let exception = exceptionJson != nil ? taskException(jsonString: exceptionJson!) : nil + let responseBody = args[4] as? String + parallelDownloadTask.chunkStatusUpdate(chunkTaskId: chunkTaskId, status: TaskStatus.init(rawValue: statusRawvalue)!, taskException: exception, responseBody: responseBody) + result(nil) + } + + private func methodUpdateChunkProgress(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as! [Any] + guard + let taskId = args[0] as? String, + let chunkTaskId = args[1] as? String, + let progress = args[2] as? Double, + let parallelDownloadTask = ParallelDownloader.downloads[taskId] + else { + result(nil) + return + } + parallelDownloadTask.chunkProgressUpdate(chunkTaskId: chunkTaskId, progress: progress) + result(nil) + } + + /// Return the authorization status of a permission, passed as the rawValue of the + /// [Permissionequest] enum + private func methodPermissionStatus(call: FlutterMethodCall, result: @escaping FlutterResult) async { + let permissionType = PermissionType(rawValue: call.arguments as! Int)! + let status = await getPermissionStatus(for: permissionType) + result(status.rawValue) + } + + /// Request this permission, passed as the rawValue of the [Permissionequest] enum + private func methodRequestPermission(call: FlutterMethodCall, result: @escaping FlutterResult) async { + let permissionType = PermissionType(rawValue: call.arguments as! Int)! + let status = await requestPermission(for: permissionType) + result(status.rawValue) + } + + /// Store or remove a configuration in shared preferences + /// + /// If the value is nil, the configuration is removed + private func storeInUserDefaults(key: String, value: Any?, result: @escaping FlutterResult) { + let defaults = UserDefaults.standard + if value != nil { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + result(nil) + } + + /// Configure the HoldingQueue (and create if necessary) + private func methodConfigHoldingQueue(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as! [Any] + if (args.isEmpty) { // deactivate the holding queue + BDPlugin.holdingQueue = nil + } else { + BDPlugin.holdingQueue = BDPlugin.holdingQueue ?? HoldingQueue() + BDPlugin.holdingQueue?.maxConcurrent = args[0] as! Int + BDPlugin.holdingQueue?.maxConcurrentByHost = args[1] as! Int + BDPlugin.holdingQueue?.maxConcurrentByGroup = args[2] as! Int + } + result(nil) + } + + /// Sets or resets flag to force failing posting on background channel + /// + /// For testing only + private func methodForceFailPostOnBackgroundChannel(call: FlutterMethodCall, result: @escaping FlutterResult) { + BDPlugin.forceFailPostOnBackgroundChannel = call.arguments as! Bool + result(nil) + } + + /// Tests the content-disposition and url translation + /// + /// For testing only + private func methodTestSuggestedFilename(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as! [Any] + guard let taskJsonString = args[0] as? String, + let contentDisposition = args[1] as? String, + let task = taskFrom(jsonString: taskJsonString) else { + result("") + return + } + let resultTask = taskWithSuggestedFilenameFromResponseHeaders(task: task, responseHeaders: ["Content-Disposition" : contentDisposition], unique: true) + result(resultTask.filename) + } + + //MARK: UIApplicationDelegate + + /// When the app restarts, recreate the urlSession if needed, and store the completion handler + public func application(_ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void) -> Bool { + if (identifier == UrlSessionDelegate.sessionIdentifier) { + os_log("Application asked to handleEventsForBackgroundURLSession", log: log, type: .info) + UrlSessionDelegate.backgroundCompletionHandler = completionHandler + UrlSessionDelegate.createUrlSession() + return true + } + return false + } + + + //MARK: UNUserNotificationCenterDelegate + + @MainActor + public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions + { + if ourCategories.contains(notification.request.content.categoryIdentifier) { + if #available(iOS 14.0, *) { + return UNNotificationPresentationOptions.list + } else { + return UNNotificationPresentationOptions.alert + } + } + return [] + } + + /// Respond to notification actions (general tap and button taps) + @MainActor + public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async + { + if ourCategories.contains(response.notification.request.content.categoryIdentifier) { + // only handle "our" categories, in case another plugin is a notification center delegate + let userInfo = response.notification.request.content.userInfo + guard + let taskAsJsonString = userInfo["task"] as? String, + let task = taskFrom(jsonString: taskAsJsonString) + else { + os_log("No task for notification tap", log: log, type: .info) + return + } + switch response.actionIdentifier { + case "pause_action": + guard let urlSessionTask = await UrlSessionDelegate.getUrlSessionTaskWithId(taskId: task.taskId) as? URLSessionDownloadTask, + let resumeData = await urlSessionTask.cancelByProducingResumeData() + else { + os_log("Could not pause task in response to notification action", log: log, type: .info) + return + } + _ = processResumeData(task: task, resumeData: resumeData) + + case "cancel_action": + let urlSessionTaskToCancel = await UrlSessionDelegate.getAllUrlSessionTasks().first(where: { + guard let taskInUrlSessionTask = getTaskFrom(urlSessionTask: $0) else { return false } + return taskInUrlSessionTask.taskId == task.taskId + }) + urlSessionTaskToCancel?.cancel() + + case "cancel_inactive_action": + processStatusUpdate(task: task, status: .canceled) + + case "resume_action": + var resumeDataAsBase64String = "" + BDPlugin.propertyLock.withLock { + resumeDataAsBase64String = BDPlugin.localResumeData[task.taskId] ?? "" + } + if resumeDataAsBase64String.isEmpty { + os_log("Resume data for taskId %@ no longer available: restarting", log: log, type: .info, task.taskId) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + _Concurrency.Task { + if await(BDPlugin.instance.doEnqueue(taskJsonString: taskAsJsonString, notificationConfigJsonString: userInfo["notificationConfig"] as? String, resumeDataAsBase64String: resumeDataAsBase64String)) == false { + os_log("Could not enqueue taskId %@ to resume", log: log, type: .info, task.taskId) + await BDPlugin.holdingQueue?.taskFinished(task) + } + } + } + + case UNNotificationDefaultActionIdentifier: + // general notification tap (no button) + guard + let notificationType = userInfo["notificationType"] as? Int + else { + os_log("No notificationType for notification tap", log: log, type: .info) + return + } + _ = postOnBackgroundChannel(method: "notificationTap", task: task, arg: notificationType) + // check 'tapOpensfile' + if notificationType == NotificationType.complete.rawValue { + guard let notificationConfigString = userInfo["notificationConfig"] as? String, + let notificationConfigData = notificationConfigString.data(using: .utf8), + let notificationConfig = try? JSONDecoder().decode(NotificationConfig.self, from: notificationConfigData) + else { + os_log("Could not extract notification config for notification tap on .complete", log: log, type: .info) + return + } + if notificationConfig.tapOpensFile { + let unpacked = unpack(packedString: task.filename) + var filePath: String? = nil + if let uri = unpacked.uri { + filePath = decodeToFileUrl(uri: uri)?.path + } else { + filePath = getFilePath(for: task) + } + if let filePath = filePath { + if !doOpenFile(filePath: filePath, mimeType: nil) { + os_log("Failed to open file on notification tap", log: log, type: .info) + } + } else { + os_log("Could not extract filePath for notification tap on .complete", log: log, type: .info) + } + } + } + // dismiss notification if it is a 'complete' or 'error' notification + if notificationType == NotificationType.complete.rawValue || notificationType == NotificationType.error.rawValue { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [response.notification.request.identifier]) + } + + default: + do {} + } + } + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/BackgroundDownloaderPlugin.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/BackgroundDownloaderPlugin.swift new file mode 100644 index 0000000..b86b048 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/BackgroundDownloaderPlugin.swift @@ -0,0 +1,16 @@ +// +// BackgroundDownloaderPlugin.swift +// background_downloader +// +// Created by Bram on 1/9/25. +// + +import Flutter +import UIKit + +@objc(BackgroundDownloaderPlugin) // Keep the Objective-C name for compatibility +public class BackgroundDownloaderPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + BDPlugin.register(with: registrar) + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Callbacks.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Callbacks.swift new file mode 100644 index 0000000..1ceef4a --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Callbacks.swift @@ -0,0 +1,81 @@ +// +// Callbacks.swift +// background_downloader +// +// Created by Bram on 10/26/24. +// + +import Foundation +import Flutter +import os.log + +import UIKit + +/// Helper function to get the callback channel with retry +private func getCallbackChannel() async -> FlutterMethodChannel? { + var retries = 0 + while BDPlugin.callbackChannel == nil && retries < 5 { + try? await _Concurrency.Task.sleep(nanoseconds: 100_000_000) // 0.1 second + retries += 1 + } + return BDPlugin.callbackChannel +} + +/// Invoke a callback in Dart (via the method channel) and return the returned Task value or nil +private func invokeCallback(withMethod methodName: String, forTask task: Task) async -> Any? { + guard let callbackChannel = await getCallbackChannel() else { + os_log("Could not invoke %{public}@ for taskId %@: callbackChannel not set", log: log, type: .error, methodName, task.taskId) + return nil + } + return await withCheckedContinuation { continuation in + os_log("Invoking %{public}@ for taskId %@", log: log, type: .info, methodName, task.taskId) + callbackChannel.invokeMethod(methodName, arguments: jsonStringFor(task: task), result: { result in + guard let jsonString = result as? String else { + if let error = result as? FlutterError { + os_log("Error invoking %{public}@: %@", log: log, type: .error, methodName, error.message ?? "nil") + } + continuation.resume(returning: nil) + return + } + if methodName == "beforeTaskStartCallback" { + continuation.resume(returning: try? JSONDecoder().decode(TaskStatusUpdate.self, from: (jsonString).data(using: .utf8)!)) + } + else { + continuation.resume(returning: taskFrom(jsonString: jsonString)) + } + }) + } +} + +/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil +func invokeBeforeTaskStartCallback(task: Task) async -> TaskStatusUpdate? { + return await invokeCallback(withMethod: "beforeTaskStartCallback", forTask: task) as? TaskStatusUpdate +} + +/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil +func invokeOnTaskStartCallback(task: Task) async -> Task? { + return await invokeCallback(withMethod: "onTaskStartCallback", forTask: task) as? Task +} + +/// Invoke the onAuthCallback in Dart (via the method channel) and return the returned Task value or nil +func invokeOnAuthCallback(task: Task) async -> Task? { + return await invokeCallback(withMethod: "onAuthCallback", forTask: task) as? Task +} + +/// Invoke the onTaskFinishedCallback in Dart (via the method channel) and return true if successful +func invokeOnTaskFinishedCallback(taskStatusUpdate: TaskStatusUpdate) async -> Bool { + guard let callbackChannel = await getCallbackChannel() else { + os_log("Could not invoke onTaskFinishedCallback: callbackChannel not set", log: log, type: .error) + return false + } + return await withCheckedContinuation { continuation in + callbackChannel.invokeMethod("onTaskFinishedCallback", arguments: jsonStringFor(taskStatusUpdate: taskStatusUpdate), result: { result in + if let error = result as? FlutterError { + os_log("Error invoking onTaskFinishedCallback: %@", log: log, type: .error, error.message ?? "nil") + continuation.resume(returning: false) + } else { + continuation.resume(returning: true) + } + }) + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Helpers.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Helpers.swift new file mode 100644 index 0000000..37c5f00 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Helpers.swift @@ -0,0 +1,128 @@ +// +// Helpers.swift +// background_downloader +// +// Created by Bram on 12/31/23. +// + +import Foundation +import UIKit +import UniformTypeIdentifiers +import os.log + +extension URL { + /// Uses .appending for iOS 16 and up, and .appendingPathComponent + /// for earlier versions + func appendingPath(_ component: String, isDirectory: Bool = false) -> URL { + if #available(iOS 16.0, *) { + return appending(path: component, directoryHint: isDirectory ? .isDirectory : .notDirectory) + } else { + return appendingPathComponent(component, isDirectory: isDirectory) + } + } + + /// Excludes URL from backup + mutating func setCloudBackup(exclude: Bool) throws { + var resource = URLResourceValues() + resource.isExcludedFromBackup = exclude + try self.setResourceValues(resource) + } +} + +/// Returns the task's URL if it can be parsed, otherwise null +func validateUrl(_ task: Task) -> URL? { + let url: URL? + // encodingInvalidCharacters is only available when compiling with Xcode 15, which uses Swift version 5.9 +#if swift(>=5.9) + if #available(iOS 17.0, *) { + url = URL(string: task.url, encodingInvalidCharacters: false) + } else { + url = URL(string: task.url) + } +#else + url = URL(string: task.url) +#endif + return url +} + +/// Converts the [map] to a [String:String] map with lowercased keys +func lowerCasedStringStringMap(_ map: [AnyHashable: Any]?) -> [String: String]? { + if map == nil {return nil} + var result: [String: String] = [:] + for (key, value) in map! { + if let stringKey = key as? String, let stringValue = value as? String { + result[stringKey.lowercased()] = stringValue + } + } + return result +} + +/// Create a temp file that contains a subset of the [fileURL] based on [start] and [contentLength] +/// +/// Returns the URL of the temp file, or nil if there was a problem (problem is logged) +func createTempFileWithRange(from fileURL: URL, start: UInt64, contentLength: UInt64) -> URL? { + let fileManager = FileManager.default + let tempDir = fileManager.temporaryDirectory + let tempFileURL = tempDir.appendingPathComponent(UUID().uuidString) // Create a unique temporary file + + // Create the temporary file + fileManager.createFile(atPath: tempFileURL.path, contents: nil, attributes: nil) + guard let inputHandle = try? FileHandle(forReadingFrom: fileURL), + let outputHandle = try? FileHandle(forWritingTo: tempFileURL) else { + os_log("Cannot open file handles for partial upload temporary file creation", log: log, type: .error) + return nil + } + defer { + try? inputHandle.close() + try? outputHandle.close() + } + let bufferSize = 1024 * 1024 // 1MB chunks + var remainingBytes = contentLength + do { + // Seek to the start position + try inputHandle.seek(toOffset: start) + while remainingBytes > 0 { + let bytesToRead = Int(min(UInt64(bufferSize), remainingBytes)) + guard let data = try inputHandle.read(upToCount: bytesToRead), !data.isEmpty else { + break // EOF + } + try outputHandle.write(contentsOf: data) + remainingBytes -= UInt64(data.count) + } + } catch { + os_log("Cannot create temporary file for partial upload: %@", log: log, type: .error, + error.localizedDescription) + return nil + } + return tempFileURL +} + + +/** + Returns mimetype of a filename based on its extension, or application/octet-stream + */ +func getMimeType(fromFilename filename: String) -> String { + // Extract the file extension from the filename + let fileExtension = (filename as NSString).pathExtension + if let type = UTType(filenameExtension: fileExtension) { + return type.preferredMIMEType ?? "application/octet-stream" + } + // Default MIME type if unable to determine + return "application/octet-stream" +} + +/// Returns the root view controller of the app, or nil if it cannot be found +/// Handles both the old AppDelegate (window) and the new SceneDelegate (connectedScenes) ways of getting the window +func getRootViewController() -> UIViewController? { + if #available(iOS 13, *) { + let window = UIApplication.shared.connectedScenes + .filter { $0.activationState == .foregroundActive } + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + if let root = window?.rootViewController { + return root + } + } + return UIApplication.shared.keyWindow?.rootViewController ?? UIApplication.shared.delegate?.window??.rootViewController +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/HoldingQueue.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/HoldingQueue.swift new file mode 100644 index 0000000..63371fd --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/HoldingQueue.swift @@ -0,0 +1,279 @@ +// +// HoldingQueue.swift +// background_downloader +// +// Created by Bram on 3/16/24. +// + +import Foundation +import os.log + +/** + * Queue that holds [EnqueueItem] items before they are actually enqueued as a URLSessionTask + * + * Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items + * can be enqueued simultaneously. + * + * Call: + * [add] to add an [EnqueueItem] + * [taskFinished] for all tasks that finish, so we may start a new one + * [cancelAllTasks] to empty the queue (sends status updates) + * [cancelTasksWithIds] to remove specific tasks (sends status updates) + * [allTasks] for a list of [Task] matching a group + * [taskForId] to get the task for a specific taskId + */ +class HoldingQueue { + var maxConcurrent: Int = 1000000 + var maxConcurrentByHost: Int = 1000000 + var maxConcurrentByGroup: Int = 1000000 + var enqueuedTaskIds = [String]() + + private var concurrent = 0 + private var concurrentByHost = [String: Int]() + private var concurrentByGroup = [String: Int]() + + private var queue = [EnqueueItem]() // Using an array as a substitute for a priority queue + + let stateLock = AsyncLock() + private var job: DispatchWorkItem? = nil // for advanceQueue in future + + /** + * Add [EnqueueItem] [item] to the queue and advance the queue if possible + */ + func add(item: EnqueueItem) async { + await stateLock.lock() + queue.append(item) + queue.sort() + enqueuedTaskIds.append(item.task.taskId) + await registerEnqueue(task: item.task, + notificationConfigJsonString: item.notificationConfigJsonString, + success: true) // for accurate groupnotification count + advanceQueue() + await stateLock.unlock() + } + + /** + * Signals to the holdingQueue that a [task] has finished + * + * Adjusts the state variables and advances the queue + * + * To prevent deadlock when called when the lock is already obtained, set [reEntry] + */ + func taskFinished(_ task: Task, reEntry: Bool = false) async { + if !reEntry { + await stateLock.lock() + } + let host = getHost(task) + concurrent -= 1 + concurrentByHost[host]? -= 1 + concurrentByGroup[task.group]? -= 1 + if let index = enqueuedTaskIds.firstIndex(of: task.taskId) { + enqueuedTaskIds.remove(at: index) + } + advanceQueue() + if (!reEntry) { + await stateLock.unlock() + } + } + + /** + * Removes all [EnqueueItem] where their taskId is in [taskIds], sends a + * [TaskStatus.canceled] update and returns a list of + * taskIds that were cancelled this way. + * + * Because this is used in combination with the UrlSessions tasks, use of this method + * requires the caller to acquire the [stateLock] + */ + func cancelTasksWithIds(_ taskIds: [String]) -> [String] { + let toRemove = queue.filter( { taskIds.contains($0.task.taskId) } ) + toRemove.forEach { item in + processStatusUpdate(task: item.task, status: .canceled) + if let configString = item.notificationConfigJsonString, + let config = notificationConfigFrom(jsonString: configString) { + _Concurrency.Task { + await updateGroupNotification(task: item.task, notificationType: .canceled, notificationConfig: config) + } + } + os_log("Canceled task with id %@", log: log, type: .info, item.task.taskId) + } + queue.removeAll(where: { taskIds.contains($0.task.taskId)}) + return toRemove.map { $0.task.taskId } + } + + /** + * Cancel (delete) all [EnqueueItem] matching [group], send a + * [TaskStatus.canceled] for each and return the number of items cancelled + * + * Because this is used in combination with the UrlSessions tasks, use of this method + * requires the caller to acquire the [stateLock] + */ + func cancelAllTasks(group: String) -> Int { + let taskIds = queue.filter({ $0.task.group == group }).map { $0.task.taskId } + return cancelTasksWithIds(taskIds).count + } + + /** + * Return task matching [taskId], or null + * + * Because this is used in combination with the UrlSessions tasks, use of this method + * requires the caller to acquire the [stateLock] + */ + func taskForId(_ taskId: String) -> Task? { + let tasks = queue.filter( { $0.task.taskId == taskId } ).map { $0.task } + if !tasks.isEmpty { + return tasks.first + } + return nil + } + + /** + * Return list of [Task] for this [group]. If [group] is nil al tasks are returned + * + * Because this is used in combination with the UrlSessions tasks, use of this method + * requires the caller to acquire the [stateLock] + */ + func allTasks(group: String?) -> [Task] { + return queue.filter( { group == nil || $0.task.group == group } ).map { $0.task } + } + + /** + * Advance the queue by signalling the queue processing coroutine + * + * Also restarts a timer that will advance the queue in 10 seconds, in case + * it dries up + */ + private func advanceQueue() { + DispatchQueue.global().async { + _Concurrency.Task { + await self.processQueue() + } + } + advanceQueueInFuture() + } + + private func advanceQueueInFuture() { + job?.cancel() + job = DispatchWorkItem { + _Concurrency.Task { + await self.calculateState() + self.advanceQueue() + } + } + guard let job = job else { return } + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 10, execute: job) + + } + + /// Processes items in the queue, if possible + private func processQueue() async { + await stateLock.lock() + var itemsToEnqueue = [EnqueueItem]() + while concurrent < maxConcurrent && !queue.isEmpty { + var mustWait = [EnqueueItem]() + var foundItem: EnqueueItem? = nil + while !queue.isEmpty { + let item = queue.removeFirst() + let host = getHost(item.task) + if (concurrentByHost[host] ?? 0) < maxConcurrentByHost && + (concurrentByGroup[item.task.group] ?? 0) < maxConcurrentByGroup { + concurrent += 1 + concurrentByHost[host, default: 0] += 1 + concurrentByGroup[item.task.group, default: 0] += 1 + foundItem = item + break + } else { + mustWait.append(item) + } + } + queue.append(contentsOf: mustWait) + queue.sort() + if let item = foundItem { + itemsToEnqueue.append(item) + } else { + break + } + } + await stateLock.unlock() + + // Enqueue items outside the stateLock to avoid blocking during network HEAD requests + for item in itemsToEnqueue { + await item.enqueue() + } + } + + /** + * Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values + * + * This is expensive, so is only done initially and when the [advanceQueueInFuture] timer + * fires + */ + private func calculateState() async { + await stateLock.lock() + UrlSessionDelegate.createUrlSession() + guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { + await stateLock.unlock() + return + } + let tasks: [Task] = urlSessionTasks.filter({ $0.state != .completed }).compactMap({ getTaskFrom(urlSessionTask: $0)}) + concurrent = tasks.count + concurrentByHost.removeAll() + concurrentByGroup.removeAll() + for task in tasks { + let host = getHost(task) + concurrentByHost[host, default: 0] += 1 + concurrentByGroup[task.group, default: 0] += 1 + } + await stateLock.unlock() + } +} + + + +/** + * Holds data related to enqueueing a task + * + * Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued) + * and in the context of the [HoldingQueue] + */ +struct EnqueueItem : Comparable { + let task: Task + let notificationConfigJsonString: String? + let resumeDataAsBase64String: String + let created = Date() + + // Comparable implementation to sort based on task priority and creation time + static func < (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool { + return lhs.task.priority == rhs.task.priority ? lhs.task.creationTime < rhs.task.creationTime : lhs.task.priority < rhs.task.priority + } + + static func == (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool { + return lhs.task.priority == rhs.task.priority && lhs.task.creationTime == rhs.task.creationTime + } + + func enqueue() async { + let success = await BDPlugin.instance.doEnqueue(taskJsonString: jsonStringFor(task: task) ?? "", notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: resumeDataAsBase64String) + if !success { + os_log("Delayed or retried enqueue failed for taskId %@", log: log, type: .info, task.taskId) + processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .general, description: "Delayed or retried enqueue failed")) + await BDPlugin.holdingQueue?.taskFinished(task, reEntry: true) + // register the failure with the notification service (for accurate groupnotification count) + await registerEnqueue(task: task, notificationConfigJsonString: notificationConfigJsonString, success: false) + } + } +} + +/// Traditional lock for asyn/await environment +actor AsyncLock { + private var isLocked = false + + func lock() async { + while isLocked { + await _Concurrency.Task.yield() + } + isLocked = true + } + + func unlock() async { + isLocked = false + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Notifications.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Notifications.swift new file mode 100644 index 0000000..0b27189 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Notifications.swift @@ -0,0 +1,483 @@ +// +// Notifications.swift +// background_downloader +// +// Created on 3/19/23. +// + +import Foundation +import UIKit +import os.log + + +/// NotificationContents +struct NotificationContents : Codable { + let title: String + let body: String + + init(json: [String:Any]) { + title = json["title"] as? String ?? "" + body = json["body"] as? String ?? "" + } +} + +struct NotificationConfig : Codable { + let running: NotificationContents? + let complete: NotificationContents? + let error: NotificationContents? + let paused: NotificationContents? + let canceled: NotificationContents? + let progressBar: Bool + let tapOpensFile: Bool + let groupNotificationId: String +} + +enum NotificationType : Int { + case running, + complete, + error, + paused, + canceled +} + +/// Data and methods related to a notification for a group of tasks +actor GroupNotification { + private static var registry: [String : GroupNotification] = [:] + private static let registryLock = NSLock() + + /// Atomically gets (or creates and registers) the notification for this + /// group. Serialised via a lock: this is called concurrently from + /// [updateGroupNotification] for every task status update, and + /// unsynchronised access to the shared dictionary corrupts it + /// (EXC_BAD_ACCESS). + static func groupNotification(named name: String, notificationConfig: NotificationConfig) -> GroupNotification { + registryLock.lock() + defer { registryLock.unlock() } + if let existing = registry[name] { + return existing + } + let created = GroupNotification(name: name, notificationConfig: notificationConfig) + registry[name] = created + return created + } + + /// Removes the notification for this group from the registry + static func remove(named name: String) { + registryLock.lock() + defer { registryLock.unlock() } + registry.removeValue(forKey: name) + } + + let name: String + let notificationConfig: NotificationConfig + + private var notifications: [Task : NotificationType] + + init(name: String, notificationConfig: NotificationConfig) { + self.name = name + self.notificationConfig = notificationConfig + self.notifications = [:] + } + + /// NotificationId derived from group name + var notificationId: String { + get { + return "groupNotification:\(name)" + } + } + + /// Total number of notifications in this group + var numTotal: Int { + get { + return notifications.count + } + } + + /// Progress expressed as [numFinished]/[numTotal], except + /// return 2.0 if numTotal is 0, to suggest that progress + /// is undetermined + var progress: Double { + get { + if numTotal == 0 { + return 2.0 + } else { + return Double(numFinished) / Double(numTotal) + } + } + } + + /// Number of "finished" notifications in this group. + /// + /// A "finished" notification is one that is not .running, + /// so includes .complete, .error, .paused + /// + var numFinished: Int { + get { + return notifications.filter { (_, v) in v != NotificationType.running }.count + } + } + + /// Number of "failed" notifications in this group. + /// + /// A "failed" notification is one of type .error + /// + var numFailed: Int { + get { + return notifications.filter { (_, v) in v == NotificationType.error }.count + } + } + + /// True if all tasks finished, regardless of outcome + var isFinished: Bool { + get { + return numFinished == numTotal + } + } + + /// + /// Return true if this group has an error + /// + var hasError: Bool { + get { + return numFailed > 0 + } + } + + /// Returns a Set of running tasks in this notificationGroup + var runningTasks: Set { + get { + return Set(notifications.filter { (_, notificationType) in + notificationType == NotificationType.running + }.keys) + } + } + + /// Int representing this group's state. If this number + /// does not change, the group state did not change. + /// + /// State is determined by the number of finished notifications + /// and the number of total notifications + private var groupState: Int { + get { + return 1000 * numTotal + numFinished + } + } + + /// Update a [task] and [notificationType] to this group, + /// and return True if this led to change in [groupState] + func update(task: Task, notificationType: NotificationType) -> Bool { + guard notifications[task] != notificationType else { + return false + } + let priorState = groupState + notifications[task] = notificationType + return priorState != groupState + } +} + + +enum NotificationCategory : String, CaseIterable { + case runningWithPause = "running_with_pause"; + case runningWithoutPause = "running_without_pause"; + case paused = "paused" + case complete = "complete" + case error = "error" + case canceled = "canceled" +} + +/// List of all category identifiers +let ourCategories = NotificationCategory.allCases.map { $0.rawValue } + +/// Update notification for this [task], based on [notificationType] and [notificationConfig] +func updateNotification(task: Task, notificationType: NotificationType, notificationConfig: NotificationConfig?) { + _Concurrency.Task { @MainActor in // run using concurrency on main thread + if (await getPermissionStatus(for: .notifications)) != .granted { + return + } + if !BDPlugin.haveregisteredNotificationCategories { + registerNotificationCategories() + BDPlugin.haveregisteredNotificationCategories = true + } + let notificationCenter = UNUserNotificationCenter.current() + if notificationConfig?.groupNotificationId.isEmpty ?? true { + // regular notification + var notification: NotificationContents? + switch notificationType { + case .running: + notification = notificationConfig?.running + case .complete: + notification = notificationConfig?.complete + case .error: + notification = notificationConfig?.error + case .paused: + notification = notificationConfig?.paused + case .canceled: + notification = notificationConfig?.canceled + } + if notification == nil { + // remove a previous notification for this task (e.g. 'running'), as on Android + notificationCenter.removeDeliveredNotifications(withIdentifiers: [task.taskId]) + return + } + let content = UNMutableNotificationContent() + content.title = await replaceTokens(input: notification!.title, task: task) + content.body = await replaceTokens(input: notification!.body, task: task) + content.userInfo = [ + "task": jsonStringFor(task: task) ?? "", + "notificationConfig": jsonStringFor(notificationConfig: notificationConfig!) ?? "", + "notificationType": notificationType.rawValue + ] + addNotificationActions(task: task, notificationType: notificationType, content: content, notificationConfig: notificationConfig!) + let request = UNNotificationRequest(identifier: task.taskId, + content: content, trigger: nil) + do { + try await notificationCenter.add(request) + } catch { + os_log("Notification error %@", log: log, type: .info, error.localizedDescription) + } + } else { + // group notification + await updateGroupNotification(task: task, notificationType: notificationType, notificationConfig: notificationConfig!) + } + } +} + + +/** + * Update notification for this [taskWorker] in group + * [notificationGroupName] and type [notificationType]. + * + * A group notification aggregates the state of all tasks in a group and + * presents a notification based on that value + */ +func updateGroupNotification( + task: Task, + notificationType: NotificationType, + notificationConfig: NotificationConfig +) async { + let groupNotificationId = notificationConfig.groupNotificationId + let groupNotification = GroupNotification.groupNotification(named: groupNotificationId, notificationConfig: notificationConfig) + let stateChange = await groupNotification.update(task: task, notificationType: notificationType) + if stateChange { + // need to update the group notification + let notificationCenter = UNUserNotificationCenter.current() + let hasError = await groupNotification.hasError + let isFinished = await groupNotification.isFinished + var notification: NotificationContents? + if isFinished { + if hasError { + notification = groupNotification.notificationConfig.error + } else { + notification = groupNotification.notificationConfig.complete + } + } else { + notification = groupNotification.notificationConfig.running + } + let notificationId = await groupNotification.notificationId + if let notification = notification { + // need to show a notification + let content = UNMutableNotificationContent() + content.title = await replaceTokens(input: notification.title, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification) + content.body = await replaceTokens(input: notification.body, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification) + // check if the notification title or body have changed relative to what may + // already be delivered, to avoid flashing notifications without change + var shouldPost = isFinished + if !shouldPost { + let existingNotifications = await notificationCenter.deliveredNotifications() + let previousNotification = existingNotifications.filter { + $0.request.identifier == notificationId + } + shouldPost = previousNotification.isEmpty || previousNotification.first?.request.content.title != content.title || previousNotification.first?.request.content.body != content.body + } + if shouldPost + { + content.categoryIdentifier = !isFinished + ? NotificationCategory.runningWithoutPause.rawValue + : (hasError ? NotificationCategory.error.rawValue : NotificationCategory.complete.rawValue) + let request = UNNotificationRequest(identifier: notificationId, + content: content, trigger: nil) + do { + try await notificationCenter.add(request) + } catch { + os_log("Notification error %@", log: log, type: .info, error.localizedDescription) + } + } + } else { + // remove notification + notificationCenter.removeDeliveredNotifications(withIdentifiers: [notificationId]) + } + if isFinished { + // remove only if not re-activated within 5 seconds + try? await _Concurrency.Task.sleep(nanoseconds: 5_000_000_000) + if await groupNotification.isFinished { + GroupNotification.remove(named: groupNotificationId) + } + } + } +} + + +/// Register that [task] was enqueued, with [success] or failure +/// +/// This is only relevant for tasks that are part of a group notification, so that the +/// 'numTotal' count is based on enqueued tasks, not on running tasks (which may be limited +/// by holdingQueue or OS restrictions). +/// [notificationConfigJsonString] is only used to detect if this task has a group notification +func registerEnqueue(task: Task, notificationConfigJsonString: String?, success: Bool) async { + guard let notificationConfigJsonString = notificationConfigJsonString, + let notificationConfig = notificationConfigFrom(jsonString: notificationConfigJsonString), + !notificationConfig.groupNotificationId.isEmpty + else { return } + await updateGroupNotification(task: task, notificationType: success ? .running : .error, notificationConfig: notificationConfig) +} + + + +/// Add action buttons to the notification +/// +/// Which button(s) depends on the [notificationType]. Action buttons are defined when defining the notification categories +func addNotificationActions(task: Task, notificationType: NotificationType, content: UNMutableNotificationContent, notificationConfig: NotificationConfig) { + BDPlugin.propertyLock.withLock( { + switch notificationType { + case .running: + content.categoryIdentifier = BDPlugin.taskIdsThatCanResume.contains(task.taskId) && notificationConfig.paused != nil ? NotificationCategory.runningWithPause.rawValue : NotificationCategory.runningWithoutPause.rawValue + case .paused: + content.categoryIdentifier = NotificationCategory.paused.rawValue + case .complete: + content.categoryIdentifier = NotificationCategory.complete.rawValue + case .error: + content.categoryIdentifier = NotificationCategory.error.rawValue + case .canceled: + content.categoryIdentifier = NotificationCategory.canceled.rawValue + } + }) +} + +/// Returns the notificationType related to this [status] +func notificationTypeForTaskStatus(status: TaskStatus) -> NotificationType { + switch status { + case .enqueued, .running: return NotificationType.running + case .complete: return NotificationType.complete + case .paused: return NotificationType.paused + case .canceled: return NotificationType.canceled + default: return NotificationType.error + } +} + +let displayNameRegEx = try! NSRegularExpression(pattern: "\\{displayName\\}", options: NSRegularExpression.Options.caseInsensitive) +let fileNameRegEx = try! NSRegularExpression(pattern: "\\{filename\\}", options: NSRegularExpression.Options.caseInsensitive) +let metaDataRegEx = try! NSRegularExpression(pattern: "\\{metadata\\}", options: NSRegularExpression.Options.caseInsensitive) +let progressRegEx = try! NSRegularExpression(pattern: "\\{progress\\}", options: NSRegularExpression.Options.caseInsensitive) +let networkSpeedRegEx = try! NSRegularExpression(pattern: "\\{networkSpeed\\}", options: NSRegularExpression.Options.caseInsensitive) +let timeRemainingRegEx = try! NSRegularExpression(pattern: "\\{timeRemaining\\}", options: NSRegularExpression.Options.caseInsensitive) +let numFinishedRegEx = try! NSRegularExpression(pattern: "\\{numFinished\\}", options: NSRegularExpression.Options.caseInsensitive) +let numFailedRegEx = try! NSRegularExpression(pattern: "\\{numFailed\\}", options: NSRegularExpression.Options.caseInsensitive) +let numTotalRegEx = try! NSRegularExpression(pattern: "\\{numTotal\\}", options: NSRegularExpression.Options.caseInsensitive) + +/// Replace special tokens {filename} and {metadata} with their respective values +func replaceTokens(input: String, task: Task, progress: Double? = nil, notificationGroup: GroupNotification? = nil) async -> String { + let inputString = NSMutableString() + inputString.append(input) + + let unpacked = unpack(packedString: task.filename) + let safeFilename = unpacked.filename ?? task.filename + + displayNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.displayName) + fileNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: safeFilename) + metaDataRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.metaData) + if (progress == nil) { + progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "")} + else { + progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(Int(progress! * 100))%") + } + networkSpeedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "-- MB/s") + timeRemainingRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "--:--") + if (notificationGroup != nil) { + let numFinished = await notificationGroup!.numFinished + let numFailed = await notificationGroup!.numFailed + let numTotal = await notificationGroup!.numTotal + numFinishedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFinished)") + numFailedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFailed)") + numTotalRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numTotal)") + } + return inputString as String +} + +/// Registers notification categories and actions for the different notification types +func registerNotificationCategories() { + // get values from shared preferences + let defaults = UserDefaults.standard + let localize = defaults.dictionary(forKey: BDPlugin.keyConfigLocalize) + + // define the actions + let cancelAction = UNNotificationAction(identifier: "cancel_action", + title: localize?["Cancel"] as? String ?? "Cancel", + options: []) + let cancelInactiveAction = UNNotificationAction(identifier: "cancel_inactive_action", + title: localize?["Cancel"] as? String ?? "Cancel", + options: []) + let pauseAction = UNNotificationAction(identifier: "pause_action", + title: localize?["Pause"] as? String ?? "Pause", + options: []) + let resumeAction = UNNotificationAction(identifier: "resume_action", + title: localize?["Resume"] as? String ?? "Resume", + options: []) + // Define the notification categories using these actions + let runningWithPauseCategory = + UNNotificationCategory(identifier: NotificationCategory.runningWithPause.rawValue, + actions: [cancelAction, pauseAction], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + let runningWithoutPauseCategory = + UNNotificationCategory(identifier: NotificationCategory.runningWithoutPause.rawValue, + actions: [cancelAction], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + let pausedCategory = + UNNotificationCategory(identifier: NotificationCategory.paused.rawValue, + actions: [cancelInactiveAction, resumeAction], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + let completeCategory = + UNNotificationCategory(identifier: NotificationCategory.complete.rawValue, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + let errorCategory = + UNNotificationCategory(identifier: NotificationCategory.error.rawValue, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + let canceledCategory = + UNNotificationCategory(identifier: NotificationCategory.canceled.rawValue, + actions: [], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + // Register the notification type. + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.setNotificationCategories([runningWithPauseCategory, runningWithoutPauseCategory, pausedCategory, completeCategory, errorCategory, canceledCategory]) +} + +/// Returns a JSON string for this NotificationConfig, or nil +func jsonStringFor(notificationConfig: NotificationConfig) -> String? { + let jsonEncoder = JSONEncoder() + guard let jsonResultData = try? jsonEncoder.encode(notificationConfig) + else { + return nil + } + return String(data: jsonResultData, encoding: .utf8) + +} + +/// Returns a NotificationConfig from the supplied jsonString, or nil +func notificationConfigFrom(jsonString: String) -> NotificationConfig? { + let decoder = JSONDecoder() + let notificationConfig: NotificationConfig? = try? decoder.decode(NotificationConfig.self, from: (jsonString).data(using: .utf8)!) + return notificationConfig +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/OpenFile.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/OpenFile.swift new file mode 100644 index 0000000..71716b5 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/OpenFile.swift @@ -0,0 +1,49 @@ +// +// OpenFile.swift +// background_downloader +// +// Created on 4/27/23. +// + +import Foundation +import MobileCoreServices +import UIKit +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif + +/// +func doOpenFile(filePath: String, mimeType: String?) -> Bool { + let fileUrl = URL(fileURLWithPath: filePath) + let documentInteractionController = UIDocumentInteractionController(url: fileUrl) + let delegate = DocumentInteractionControllerDelegate() + documentInteractionController.delegate = delegate + if (mimeType != nil) { + if #available(iOS 14.0, *) { + if let type = UTType(mimeType: mimeType!) { + documentInteractionController.uti = type.identifier + } + } else { + if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType! as NSString, nil)?.takeRetainedValue() + { + documentInteractionController.uti = uti as String + } + } + } + guard let view = getRootViewController()?.view + else { + return false + } + if !documentInteractionController.presentPreview(animated: true) { + documentInteractionController.presentOpenInMenu(from: view.bounds, in: view, animated: true) + return true + } + return true +} + +class DocumentInteractionControllerDelegate: NSObject, UIDocumentInteractionControllerDelegate { + + func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { + return getRootViewController()! + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/ParallelDownloader.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/ParallelDownloader.swift new file mode 100644 index 0000000..1f9efd5 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/ParallelDownloader.swift @@ -0,0 +1,463 @@ +// +// ParallelDownloader.swift +// background_downloader +// +// Created on 9/18/23. +// + +import Foundation +import os.log + +let chunkGroup = "chunk" + +func scheduleParallelDownload(task: Task, taskDescription: String, baseRequest: URLRequest, resumeData: String) async -> Bool +{ + let isResume = !resumeData.isEmpty + let parallelDownload = ParallelDownloader(task: task) + if !isResume { + return await withCheckedContinuation { continuation in + let dataTask = URLSession.shared.dataTask(with: baseRequest) { (data, response, error) in + if let httpResponse = response as? HTTPURLResponse, error == nil { + if httpResponse.statusCode == 404 { + os_log("URL not found for taskId %@", log: log, type: .info, task.taskId) + } + else if !parallelDownload.start(responseStatusCode: httpResponse.statusCode, contentLengthFromHeader: Int64(httpResponse.value(forHTTPHeaderField: "Content-Length") ?? "-1") ?? -1, responseHeaders: httpResponse.allHeaderFields ) { + os_log("Cannot chunk or enqueue download", log: log, type: .info) + } else { + if BDPlugin.holdingQueue?.enqueuedTaskIds.contains(task.taskId) != true { + // only send .enqueued if we didn't already do that when + // enqueueing with the holdingQueue + processStatusUpdate(task: task, status: TaskStatus.enqueued) + } + continuation.resume(returning: true) + return + } + } else { + os_log("Error making HEAD request for taskId %@", log: log, type: .info, task.taskId) + } + continuation.resume(returning: false) + } + dataTask.priority = 1 - Float(task.priority) / 10 + dataTask.resume() + } + } else { + // resume + return parallelDownload.resume(resumeData: resumeData) + } +} + +/** + / Manages a parallel download + / + / A ParallelDownloadTask pings the server to get the content-length of the + / download, then creates a list of [Chunk]s, each representing a portion + / of the download. Each chunk-task has its group set to 'chunk' and + / has the taskId of the parent ParallelDownloadTask in its + / [Task.metaData] field. + / The ParallelDownloader sends 'enqueue' messages back to the NativeDownloader to + / start each chunk-task, just like any other download task. + / Messages with group 'chunk' are intercepted in the NativeDownloader, + / and the update is sent to the ParallelDownloader. + / In the ParallelDownloader, the update is processed and the new status/progress + / of the ParallelDownloadTask is determined. If the status/progress has + / changed, an update is sent and the status is processed (e.g., a complete + / status triggers the piecing together of the downloaded file from + / its chunk pieces). + / + / Similarly, pause and cancel commands are sent to all chunk tasks before + / updating the status of the parent ParallelDownloadTask + */ +public class ParallelDownloader: NSObject { + // downloads is the list of active parallel downloads, used to route child + // status and progress updates + static var downloads: [String : ParallelDownloader] = [:] // keyed by parentTask.taskId + var parentTask: Task + var chunks: [Chunk] = [] + var parallelDownloadContentLength: Int64 = 0 + var lastTaskStatus: TaskStatus = .enqueued + var lastProgress: Double = 0 + var nextProgressUpdateTime = Date() + var taskException: TaskException? = nil + var responseBody: String? = nil + var responseHeaders: [AnyHashable : Any]? = nil + var initialResponseStatusCode: Int? = nil + + /// Create a new ParallelDownloader + init(task:Task) { + self.parentTask = task + } + + /// Start the parallel download by creating and enqueueing chunks based on + /// the content length and header fields + /// + /// Returns false if start was unsuccessful + public func start(responseStatusCode: Int, contentLengthFromHeader: Int64, responseHeaders: [AnyHashable: Any]) -> Bool { + if !(200...206).contains(responseStatusCode) { + os_log("TaskId %@ returned response code %d", log: log, type: .info, parentTask.taskId, responseStatusCode) + return false + } + initialResponseStatusCode = responseStatusCode // may change due to chunk errors + self.responseHeaders = responseHeaders + // get suggested filename if needed + if parentTask.filename == "?" { + let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: parentTask, responseHeaders: responseHeaders) + if newTask.filename != parentTask.filename { + // store for future replacement, and replace now + storeModifiedTask(task: newTask) + parentTask = newTask + } + } + parallelDownloadContentLength = contentLengthFromHeader > 0 + ? contentLengthFromHeader + : getContentLength(responseHeaders: responseHeaders, task: self.parentTask) + if parallelDownloadContentLength <= 0 { + os_log("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header", log: log, type: .info) + return false + } + if responseHeaders.first(where: { entry in (entry.key as! String).lowercased() == "accept-ranges" && entry.value as! String == "bytes"}) == nil { + os_log("Server does not accept ranges - cannot chunk download", log: log, type: .info) + return false + } + extractContentType(responseHeaders: responseHeaders, task: self.parentTask) + ParallelDownloader.downloads[parentTask.taskId] = self + chunks = createChunks(task: parentTask, contentLength: parallelDownloadContentLength) + let success = !chunks.isEmpty && enqueueChunkTasks() + if !success { + ParallelDownloader.downloads.removeValue(forKey: parentTask.taskId) + } + return success + } + + /// resume: reconstruct [chunks] and wait for all chunk tasks to complete. + /// The Dart side will resume each chunk task, so we just wait for the + /// completer to complete + func resume(resumeData: String) -> Bool { + ParallelDownloader.downloads[parentTask.taskId] = self + let decoder = JSONDecoder() + guard + let chunkList = try? decoder.decode([Chunk].self, from: resumeData.data(using: .utf8)!) + else { + os_log("Could not decode resumeData for taskid %@", log: log, type: .info, parentTask.taskId) + return false + } + chunks = chunkList + parallelDownloadContentLength = chunks.reduce(0, { partialResult, chunk in + partialResult + chunk.toByte - chunk.fromByte + 1 + }) + lastTaskStatus = .paused + return true + } + + /// Returns a list of chunk information for this task, and sets + /// [parallelDownloadContentLength] to the total length of the download + /// + /// Throws a StateError if any information is missing, which should lead + /// to a failure of the [ParallelDownloadTask] + func createChunks(task: Task, contentLength: Int64) -> [Chunk] { + let numChunks = task.urls!.count * task.chunks! + if contentLength <= 0 { + os_log("Server does not provide content length - cannot chunk download", log: log, type: .info) + return [] + } + parallelDownloadContentLength = contentLength + let chunkSize = (contentLength / Int64(numChunks)) + 1 + var chunksList: [Chunk] = [] + for i in 0.. Bool { + let jsonEncoder = JSONEncoder() + for chunk in chunks { + guard let taskAsJsonData = try? jsonEncoder.encode(chunk.task) + else { + return false + } + if !postOnBackgroundChannel(method: "enqueueChild", task: parentTask, arg: String(data: taskAsJsonData, encoding: .utf8) as Any) { + os_log("Could not enqueue child task for chunk", log: log, type: .info) + return false + } + } + return true + } + + /// Process incoming [status] update for a chunk with [chunkTaskId] + /// + /// If status is failure, may include [taskException] and [responseBody] + func chunkStatusUpdate(chunkTaskId: String, status: TaskStatus, taskException: TaskException?, responseBody: String?) { + // Confirm chunk is part of this parent task + guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else { return } + // first check for fail -> retry + if status == .failed && chunk.task.retriesRemaining > 0 { + chunk.task.retriesRemaining -= 1 + let waitTimeSeconds = 2 << min(chunk.task.retries - chunk.task.retriesRemaining - 1, 8) + os_log("Chunk with taskId %@ failed, waiting %d seconds to retry; %d retries remaining", log: log, type: .info, chunk.task.taskId, waitTimeSeconds, chunk.task.retriesRemaining) + DispatchQueue.main.asyncAfter(deadline: .now() + Double(waitTimeSeconds)) { + if !postOnBackgroundChannel( + method: "enqueueChild", + task: self.parentTask, + arg: jsonStringFor(task: chunk.task)! + ) { + self.chunkStatusUpdate(chunkTaskId: chunkTaskId, status: .failed, taskException: taskException, responseBody: responseBody) + } + } + } else { + // no retry + let newStatusUpdate = updateChunkStatus(chunk: chunk, status: status) + if let newStatusUpdate = newStatusUpdate { + switch newStatusUpdate { + case .running: + processStatusUpdate(task: parentTask, status: .running) + case .complete: + let stitchResult = stitchChunks() + if stitchResult == TaskStatus.complete { + os_log("Finished task with id %@", log: log, type: .info, parentTask.taskId) + } + finishTask(status: stitchResult) + case .failed: + self.taskException = taskException + cancelAllChunkTasks() + finishTask(status: .failed) + case .notFound: + self.responseBody = responseBody + cancelAllChunkTasks() + finishTask(status: .notFound) + default: + // ignore all other status updates + break + } + } + } + } + + /// Process incoming [progress] update for a chunk with [chunkTaskId]. + /// + /// Recalculates overall task progress (based on the average of the chunk + /// task progress) and sends an updatre to the Dart isde and updates the + /// notification at the appropriate interval + func chunkProgressUpdate(chunkTaskId: String, progress: Double) { + guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else { + return // chunk is not part of this parent task + } + if progress > 0 && progress < 1 { + let parentProgress = updateChunkProgress(chunk: chunk, progress: progress) + let totalBytesDone = Int64(parentProgress * Double(parallelDownloadContentLength)) + updateProgress(task: parentTask, totalBytesExpected: parallelDownloadContentLength, totalBytesDone: totalBytesDone) + } + } + + /// Update the status for this chunk, and return the status for the parent task + /// as derived from the sum of the child tasks, or null if undefined + /// + /// The updates are received from the NativeDownloader, which intercepts + /// status updates for the chunkGroup + private func updateChunkStatus(chunk: Chunk, status: TaskStatus) -> TaskStatus? { + chunk.status = status + let parentStatus = parentTaskStatus() + if let parentStatus = parentStatus, parentStatus != lastTaskStatus { + lastTaskStatus = parentStatus + return parentStatus + } + return nil + } + + /// Returns the [TaskStatus] for the parent of this chunk, as derived from + /// the 'sum' of the child tasks, or nil if undetermined + /// + /// The updates are received from the NativeDownloader, which intercepts + /// status updates for the chunkGroup + private func parentTaskStatus() -> TaskStatus? { + if chunks.first(where: { $0.status == .failed }) != nil { + return .failed + } + if chunks.first(where: { $0.status == .notFound }) != nil { + return .notFound + } + if (chunks.reduce(into: 0) { previousValue, chunk in + if chunk.status == .running { + previousValue += 1 + } + }) == 1 { + return .running // if exactly one chunk task is running + } + if chunks.allSatisfy({ $0.status == .complete }) { + return .complete + } + return nil + } + + /// Updates the chunk's progress and returns the average progress + /// + /// Returns the [progress] for the parent of this chunk, as derived from + /// its children by averaging + private func updateChunkProgress(chunk: Chunk, progress: Double) -> Double { + chunk.progress = progress + return chunks.reduce(into: 0.0) { previousValue, chunk in + previousValue += chunk.progress + } / Double(chunks.count) + } + + /// Stitch all chunks together into one file, per the [parentTask] + private func stitchChunks() -> TaskStatus { + do { + let fileManager = FileManager.default + let outputFilePath = getFilePath(for: parentTask)! + let outputFile = URL(fileURLWithPath: outputFilePath) + if fileManager.fileExists(atPath: outputFilePath) { + try? fileManager.removeItem(at: outputFile) + } + fileManager.createFile(atPath: outputFilePath, contents: nil) + let outputFileHandle = try FileHandle(forWritingTo: outputFile) + defer { + outputFileHandle.closeFile() + for chunk in chunks { + let inputFilePath = getFilePath(for: chunk.task)! + let inputFileURL = URL(fileURLWithPath: inputFilePath) + if fileManager.fileExists(atPath: inputFilePath) { + try? FileManager.default.removeItem(at: inputFileURL) + } + } + } + for chunk in chunks.sorted(by: { $0.fromByte < $1.fromByte }) { + let filePath = getFilePath(for: chunk.task)! + let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath)) + defer { + fileHandle.closeFile() + } + while true { + let data = fileHandle.readData(ofLength: 2 << 13) + if data.isEmpty { + break + } + outputFileHandle.write(data) + } + } + } catch { + os_log("Error stitching chunks: %@", log: log, type: .info, error.localizedDescription) + taskException = TaskException(type: .fileSystem, description: "Error stitching chunks: \(error.localizedDescription)") + return .failed + } + return .complete + } + + /// Cancel this task + /// + /// Cancels all chunk tasks and completes the task with [TaskStatus.canceled] + func cancelTask() { + cancelAllChunkTasks() + finishTask(status: .canceled) + } + + /// Pause this task + /// + /// Pauses all chunk tasks + func pauseTask() async -> Bool { + let encoder = JSONEncoder() + guard + let chunkTasksData = try? encoder.encode(chunks.map({ chunk in + chunk.task })), + let chunksData = try? encoder.encode(chunks) + else { + return false + } + if !postOnBackgroundChannel(method: "pauseTasks", task: parentTask, arg: String(data: chunkTasksData, encoding: .utf8)!) + { + os_log("Could not pause chunk tasks for taskId %@", log: log, type: .info, parentTask.taskId) + return false + } + if !postOnBackgroundChannel(method: "resumeData", task: parentTask, arg: String(data: chunksData, encoding: .utf8)!) { + os_log("Could not post resume data for taskId %@", log: log, type: .info, parentTask.taskId) + // because we already paused the + cancelAllChunkTasks() + processStatusUpdate(task: parentTask, status: .failed) + return false + } + processStatusUpdate(task: parentTask, status: .paused) + return true + } + + /// Cancel the tasks associated with each chunk + /// + /// Accomplished by sending list of taskIds to cancel to the NativeDownloader + private func cancelAllChunkTasks() { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(chunks.map({ $0.task.taskId })) else { + os_log("Could not encode chunk ids", log: log, type: .error) + return + } + if !postOnBackgroundChannel(method: "cancelTasksWithId", task: parentTask, arg: String(data: data, encoding: .utf8)!) { + os_log("Could not cancel chunk tasks related to taskId %@", log: log, type: .info, parentTask.taskId) + } + } + + /// Finish the [ParallelDownloadTask] by posting a statusUpdate and clearning up + private func finishTask(status: TaskStatus) { + let taskId = parentTask.taskId + let (mimeType, charSet) = BDPlugin.propertyLock.withLock({ + (BDPlugin.mimeTypes[taskId], BDPlugin.charSets[taskId]) + }) + var responseStatusCode: Int? = nil + switch status { + case .complete: + responseStatusCode = initialResponseStatusCode + + case .notFound: + responseStatusCode = 404 + + default: + responseStatusCode = nil + } + processStatusUpdate(task: parentTask, status: status, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode, mimeType: mimeType, charSet: charSet) + BDPlugin.propertyLock.withLock({ + BDPlugin.mimeTypes.removeValue(forKey: taskId) + BDPlugin.charSets.removeValue(forKey: taskId) + BDPlugin.tasksWithModifications.removeValue(forKey: taskId) + ParallelDownloader.downloads.removeValue(forKey: taskId) + }) + } +} + +/// Represents one chunk of a parallel download +/// +/// Initialized to a specific range of the parent task. +/// Holds state (status and progress) of the chunk +public class Chunk: NSObject, Codable { + let parentTaskId: String + let url: String + let filename: String + let fromByte: Int64 + let toByte: Int64 + var task: Task + var status: TaskStatus = TaskStatus.enqueued + var progress = 0.0 + + + init(parentTask: Task, url: String, filename: String, fromByte: Int64, toByte: Int64) { + self.parentTaskId = parentTask.taskId + self.url = url + self.filename = filename + self.fromByte = fromByte + self.toByte = toByte + var headers = parentTask.headers + headers["Range"] = "bytes=\(fromByte)-\(toByte)" + let jsonEncoder = JSONEncoder() + let data = try? jsonEncoder.encode(["parentTaskId": self.parentTaskId, "from": String(fromByte), "to": String(toByte)]) + let metaData = data != nil ? String(data: data!, encoding: .utf8) ?? "" : "" + self.task = Task(url: url, filename: filename, headers: headers, baseDirectory: BaseDirectory.temporary.rawValue, group: chunkGroup, updates: Chunk.updatesBasedOnParent(parentTask), retries: parentTask.retries, retriesRemaining: parentTask.retries, allowPause: parentTask.allowPause, priority: parentTask.priority, metaData: metaData, taskType: "DownloadTask") + } + + /// Returns [Updates] enum rawValue based on its parent + static func updatesBasedOnParent(_ parentTask: Task) -> Int { + return parentTask.updates == Updates.none.rawValue || parentTask.updates == Updates.statusChange.rawValue ? Updates.statusChange.rawValue : Updates.statusChangeAndProgressUpdates.rawValue + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Permissions.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Permissions.swift new file mode 100644 index 0000000..11a444d --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Permissions.swift @@ -0,0 +1,154 @@ +// +// Permissions.swift +// background_downloader +// +// Created by Bram on 11/30/23. +// + +import Foundation +import Photos +import UserNotifications +import os.log + +public enum PermissionType: Int { + case notifications, + androidSharedStorage, + iosAddToPhotoLibrary, + iosChangePhotoLibrary +} + +public enum PermissionStatus: Int { + case undetermined, + denied, + granted, + partial, + requestError } + +/// Get current permission status for this [request] +/// +/// Unknown permissions resturn .granted +public func getPermissionStatus(for permissionType: PermissionType) async -> PermissionStatus { + if permissionType == .notifications { +#if BYPASS_PERMISSION_NOTIFICATIONS + return .denied +#else + let center = UNUserNotificationCenter.current() + let status = (await center.notificationSettings()).authorizationStatus + switch status { + case .authorized: + return .granted + + case .denied: + return .denied + + case .notDetermined: + return .undetermined + + default: + return .partial + } +#endif + } + if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) { + let status: PHAuthorizationStatus + if permissionType == .iosAddToPhotoLibrary { +#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY + status = .denied +#else + if #available(iOS 14, *) { + status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + } else { + status = PHPhotoLibrary.authorizationStatus() + } +#endif + } else { // readwrite +#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY + status = .denied +#else + if #available(iOS 14, *) { + status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + } else { + status = PHPhotoLibrary.authorizationStatus() + } +#endif + } + switch status { + case .authorized: + return .granted + + case .denied: + return .denied + + case .notDetermined: + return .undetermined + + default: + return .partial + } + } + return .granted // default return if irrelevant permission for iOS +} + +/// Request permission from user and return the [PermissionResult] +/// +/// Unknown permissions are granted +public func requestPermission(for permissionType: PermissionType) async -> PermissionStatus { + if permissionType == .notifications { +#if BYPASS_PERMISSION_NOTIFICATIONS + return .denied +#else + let center = UNUserNotificationCenter.current() + guard let granted = try? await center.requestAuthorization(options: [.alert]) else { + return .requestError + } + return granted ? .granted : .denied +#endif + } + if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) { + let status: PHAuthorizationStatus + if permissionType == .iosAddToPhotoLibrary { +#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY + status = .denied +#else + if #available(iOS 14, *) { + status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + } else { + status = await withCheckedContinuation { continuation in + PHPhotoLibrary.requestAuthorization {status in + continuation.resume(returning: status) + } + } + } +#endif + } else { // readwrite +#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY + status = .denied +#else + if #available(iOS 14, *) { + status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + } else { + status = await withCheckedContinuation { continuation in + PHPhotoLibrary.requestAuthorization {status in + continuation.resume(returning: status) + } + } + } +#endif + } + switch status { + case .authorized: + return .granted + + case .denied: + return .denied + + case .notDetermined: + return .undetermined + + default: + return .partial + } + } + return .granted + +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..554dcca --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy @@ -0,0 +1,36 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/SharedStorage.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/SharedStorage.swift new file mode 100644 index 0000000..fb69832 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/SharedStorage.swift @@ -0,0 +1,200 @@ +// +// SharedStorage.swift +// background_downloader +// +// Created on 3/26/23. +// + +import Foundation +import os.log +import Photos +import Combine + +public enum SharedStorage: Int { + case downloads, + images, + video, + audio, + files, + external +} + +/// Check if photos permissions have been granted +/// +/// if [addOnly] is true, checks .addOnly permission, otherwise checks +/// full .readWrite permission. +/// +/// Returns true if granted + +/// .addOnly requires the "NSPhotoLibraryAddUsageDescription" to be set in Info.plist +/// .readWrite requires the "NSPhotoLibraryUsageDescription" to be set in Info.plist +/// Prior to iOS 14, use NSPhotoLibraryUsageDescription +public func photosAccessAuthorized(addOnly: Bool) async -> Bool { + let permissionType: PermissionType = addOnly ? .iosAddToPhotoLibrary : .iosChangePhotoLibrary + var authStatus = await getPermissionStatus(for: permissionType) + if (authStatus == .granted) { + return true + } + return false +} + +/// Move file at [filePath] to shared storage [destination] with optional [directory] +public func moveToSharedStorage(filePathOrUriString: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? { + let fileUrl = URL(string: filePathOrUriString) + let filePath = fileUrl?.scheme == "file" ? fileUrl!.path : filePathOrUriString + guard FileManager().fileExists(atPath: filePath) + else { + os_log("Cannot move to shared storage: file %@ does not exist", log: log, type: .error, filePathOrUriString) + return nil + } + switch destination { + case .images, .video: + return await moveToPhotoLibrary(filePath: filePath, destination: destination) + default: + return moveToFakeSharedStorage(filePath: filePath, destination: destination, directory: directory, asUriString: asUriString) + } +} + +/// Saves the file at [filePath] to the photolibrary [destination] and removes the original +/// +/// The returned path is the 'localId' +private func moveToPhotoLibrary(filePath: String, destination: SharedStorage) async -> String? { + var localId: String? + guard await photosAccessAuthorized(addOnly: true) else { + os_log("Cannot move to shared storage: permission to add to photos library denied by user", log: log, type: .info) + return nil + } +#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY + return nil +#else + guard let fileURL = URL(string: filePath) else { + os_log("filePath invalid: %@", log: log, type: .info, filePath) + return nil + } + let result = await withCheckedContinuation { continuation in + PHPhotoLibrary.shared().performChanges({ + let assetChangeRequest = destination == .video ? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL) : + PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL) + localId = assetChangeRequest?.placeholderForCreatedAsset?.localIdentifier + }) + { (success, error: Error?) in + guard success, let unwrappedId = localId else { + if let unwrappedError = error { + os_log("Could not save to Photos Library: %@", log: log, type: .info, String(describing: unwrappedError)) + } + continuation.resume(returning: "") + return + } + try? FileManager.default.removeItem(at: fileURL) + continuation.resume(returning: unwrappedId) + } + } + return result.isEmpty ? nil : result +#endif +} + +/// Returns the path to the file at [filePath] in shared storage [destination] subdir [directory], or null +public func pathInSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? { + if destination == .images || destination == .video { + return await pathInPhotoLibrary(localId: filePath, destination: destination) + } + // regular 'faked' sharedStorage + guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else { + os_log("Cannot determine path in shared storage: no permission for directory %@", log: log, type: .info, directory) + return nil + } + let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent) + return asUriString ? destUrl.absoluteString : destUrl.path +} + +/// Returns the file path associated with the [localId] in the Photos library +/// +/// This requires .readWrite PhotoLibrary access, and therefore the filepath is NOT returned +/// when moving a photo or video to the PhotoLibrary using [moveToSharedStorage]. +/// Instead, the local identifier is returned, and if the full file path is required, this identifier needs to +/// be passed to the [pathInSharedStorage] call +/// +/// Returns the path to the photo/video asset, or nil if unsuccessful +public func pathInPhotoLibrary(localId: String, destination: SharedStorage) async -> String? { + guard await photosAccessAuthorized(addOnly: false) else { + os_log("Cannot get path in shared storage: permission to access photos library denied by user", log: log, type: .info) + return nil + } +#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY + return nil +#else + let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: [localId], options: nil) + guard let asset = assetResult.firstObject else { + os_log("Photos asset not found", log: log, type: .info) + return nil + } + let result = await withCheckedContinuation { continuation in + asset.requestContentEditingInput(with: nil, completionHandler: { input, _ in + if (destination == .video) { + if let urlAsset = input?.audiovisualAsset as? AVURLAsset { + continuation.resume(returning: urlAsset.url.path) + } else { + continuation.resume(returning: "") + } + } else { + continuation.resume(returning: input?.fullSizeImageURL?.path ?? "") + } + }) + } + return result.isEmpty ? nil : result +#endif +} + + +/// Returns the URL of the directory associated with the [destination] and [directory], or nil +public func directoryForSharedStorage(destination: SharedStorage, directory: String) throws -> URL? { + var dir: String + switch destination { + case .downloads: + dir = "Downloads" + case .audio: + dir = "Music" + case .images, .video: + os_log("Destination .images and .video should use PhotoLibrary functions", log: log, type: .error) + return nil + case .files, .external: + os_log("Destination .files and .external are not supported on iOS", log: log, type: .info) + return nil + + } + let documentsURL = + try? FileManager.default.url(for: FileManager.SearchPathDirectory.documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + return directory.isEmpty + ? documentsURL?.appendingPath(dir, isDirectory: true) + : documentsURL?.appendingPath(dir, isDirectory: true).appendingPath(directory, isDirectory: true) +} + +private func moveToFakeSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) -> String? { + let fileUrl = NSURL(fileURLWithPath: filePath) + guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else { + os_log("Cannot move to shared storage: no permission for directory %@", log: log, type: .error, directory) + return nil + } + if !FileManager.default.fileExists(atPath: directory.path) { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + os_log("Cannot move to shared storage: failed to create directory %@: %@", log: log, type: .error, directory.path, error.localizedDescription) + return nil + } + } + let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent) + if FileManager.default.fileExists(atPath: destUrl.path) { + try? FileManager.default.removeItem(at: destUrl) + } + do { + try FileManager.default.moveItem(at: fileUrl as URL, to: destUrl) + } catch { + os_log("Failed to move file %@ to %@: %@", log: log, type: .error, filePath, destUrl.path, error.localizedDescription) + return nil + } + return asUriString ? destUrl.absoluteString : destUrl.path +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Task.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Task.swift new file mode 100644 index 0000000..8fb3ea0 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Task.swift @@ -0,0 +1,328 @@ +// +// Task.swift +// background_downloader +// +// Created on 3/4/23. +// + +import Foundation + + +/// Partial version of the Dart side DownloadTask, only used for background loading +public struct Task : Codable, Hashable { + public var taskId: String = "\(Int.random(in: 1..<(1 << 32)))" + public var url: String + public var urls: [String]? = [] + public var filename: String + public var headers: [String:String] = [:] + public var httpRequestMethod: String = "GET" + public var chunks: Int? = 1 + public var post: String? + public var postIsBinary: Bool? + public var fileField: String? + public var mimeType: String? + public var fields: [String:String]? + public var directory: String = "" + public var baseDirectory: Int + public var group: String + public var updates: Int + public var requiresWiFi: Bool = false + public var retries: Int = 0 + public var retriesRemaining: Int = 0 + public var allowPause: Bool = false + public var priority: Int = 5 + public var metaData: String = "" + public var displayName: String = "" + public var creationTime: Int64 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) + var options: TaskOptions? + var transferHints: [Int]? + var stallTimeout: Int64? + public var taskType: String +} + +extension Task { + func copyWith(taskId: String? = nil, + url: String? = nil, + urls: [String]? = nil, + filename: String? = nil, + headers: [String:String]? = nil, + httpRequestMethod: String? = nil, + chunks: Int? = nil, + post: String? = nil, + fileField: String? = nil, + mimeType: String? = nil, + fields: [String:String]? = nil, + directory: String? = nil, + baseDirectory: Int? = nil, + group: String? = nil, + updates: Int? = nil, + requiresWiFi: Bool? = nil, + retries: Int? = nil, + retriesRemaining: Int? = nil, + allowPause: Bool? = nil, + priority: Int? = nil, + metaData: String? = nil, + displayName: String? = nil, + creationTime: Int64? = nil, + options: TaskOptions? = nil, + transferHints: [Int]? = nil, + stallTimeout: Int64? = nil, + taskType: String? = nil) -> Task { + + var copiedTask = self + + if let taskId = taskId { + copiedTask.taskId = taskId + } + + if let url = url { + copiedTask.url = url + } + + if let urls = urls { + copiedTask.urls = urls + } + + if let filename = filename { + copiedTask.filename = filename + } + + if let headers = headers { + copiedTask.headers = headers + } + + if let httpRequestMethod = httpRequestMethod { + copiedTask.httpRequestMethod = httpRequestMethod + } + + if let chunks = chunks { + copiedTask.chunks = chunks + } + + if let post = post { + copiedTask.post = post + } + + if let fileField = fileField { + copiedTask.fileField = fileField + } + + if let mimeType = mimeType { + copiedTask.mimeType = mimeType + } + + if let fields = fields { + copiedTask.fields = fields + } + + if let directory = directory { + copiedTask.directory = directory + } + + if let baseDirectory = baseDirectory { + copiedTask.baseDirectory = baseDirectory + } + + if let group = group { + copiedTask.group = group + } + + if let updates = updates { + copiedTask.updates = updates + } + + if let requiresWiFi = requiresWiFi { + copiedTask.requiresWiFi = requiresWiFi + } + + if let retries = retries { + copiedTask.retries = retries + } + + if let retriesRemaining = retriesRemaining { + copiedTask.retriesRemaining = retriesRemaining + } + + if let allowPause = allowPause { + copiedTask.allowPause = allowPause + } + + if let priority = priority { + copiedTask.priority = priority + } + + if let metaData = metaData { + copiedTask.metaData = metaData + } + + if let displayName = displayName { + copiedTask.displayName = displayName + } + + if let creationTime = creationTime { + copiedTask.creationTime = creationTime + } + + if let options = options { + copiedTask.options = options + } + + if let transferHints = transferHints { + copiedTask.transferHints = transferHints + } + + if let stallTimeout = stallTimeout { + copiedTask.stallTimeout = stallTimeout + } + + if let taskType = taskType { + copiedTask.taskType = taskType + } + + return copiedTask + } +} + +/// Base directory in which files will be stored, based on their relative +/// path. +/// +/// These correspond to the directories provided by the path_provider package +enum BaseDirectory: Int { + case applicationDocuments, // getApplicationDocumentsDirectory() + temporary, // getTemporaryDirectory() + applicationSupport, // getApplicationSupportDirectory() + applicationLibrary, // getLibraryDirectory() + root // system root +} + +/// Type of updates requested for a group of downloads +enum Updates: Int { + case none, // no status or progress updates + statusChange, // only calls upon change in DownloadTaskStatus + progressUpdates, // only calls for progress + statusChangeAndProgressUpdates // calls also for progress along the way +} + +/// Holds various options related to the task that are not included in the +/// task's properties, as they are rare +struct TaskOptions : Codable, Hashable { + var beforeTaskStartRawHandle: Int64? + var onTaskStartRawHandle: Int64? + var onTaskFinishedRawHandle: Int64? + var auth: Auth? + + /// True if [TaskOptions] contains a callback for beforeTaskStart + func hasBeforeStartCallback() -> Bool { + beforeTaskStartRawHandle != nil + } + + /// True if [TaskOptions] contains a callback for onTaskStart + func hasOnStartCallback() -> Bool { + onTaskStartRawHandle != nil + } + + /// True if [TaskOptions] contains a callback that must be called after the task finishes + func hasOnFinishedCallback() -> Bool { + onTaskFinishedRawHandle != nil + } +} + +/// Defines a set of possible states which a [DownloadTask] can be in. +public enum TaskStatus: Int, Codable { + case enqueued, + running, + complete, + notFound, + failed, + canceled, + waitingToRetry, + paused +} + +/** Holds data associated with a task status update, for local storage */ +public struct TaskStatusUpdate: Codable { + public var task: Task + public var taskStatus: TaskStatus + public var exception: TaskException? + public var responseBody: String? + public var responseStatusCode: Int? + public var responseHeaders: [String: String]? + public var mimeType: String? + public var charSet: String? + + func argList() -> [Any?] { + let finalState = isFinalState(status: taskStatus) + return taskStatus == .failed + ? [taskStatus.rawValue, + exception?.type.rawValue, + exception?.description, + exception?.httpResponseCode, + responseBody] + : [taskStatus.rawValue, + finalState ? responseBody : nil, + finalState ? responseHeaders : nil, + (taskStatus == .complete || taskStatus == .notFound) ? responseStatusCode : nil, + finalState ? mimeType : nil, + finalState ? charSet : nil] + } +} + +/** Holds data associated with a task progress update, for local storage */ +struct TaskProgressUpdate: Encodable { + var task: Task + var progress: Double + var expectedFileSize: Int64 +} + +/** Holds data associated with a resume, for local storage */ +struct ResumeData: Encodable { + var task: Task + var data: String +} + +/// The type of [TaskException] +public enum ExceptionType: String, Codable { + case + + // General error + general = "TaskException", + + + // Could not save or find file, or create directory + fileSystem = "TaskFileSystemException", + + // URL incorrect + url = "TaskUrlException", + + // Connection problem, eg host not found, timeout + connection = "TaskConnectionException", + + // Could not resume or pause task + resume = "TaskResumeException", + + // Invalid HTTP response + httpResponse = "TaskHttpException" +} + +/** + * Contains error information associated with a failed [Task] + * + * The [type] categorizes the error + * The [httpResponseCode] is only valid if >0 and may offer details about the + * nature of the error + * The [description] is typically taken from the platform-generated + * error message, or from the plugin. The localization is undefined + */ +public struct TaskException : Codable { + public var type: ExceptionType + public var httpResponseCode: Int = -1 + public var description: String +} + +func taskException(jsonString: String) -> TaskException { + if let jsonMap = try! JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: []) as? [String: Any] { + return TaskException(type: ExceptionType(rawValue: jsonMap["type"] as! String) ?? ExceptionType.general + , httpResponseCode: jsonMap["httpResponseCode"] as? Int ?? -1, description: jsonMap["description"] as! String ) + } + return TaskException(type: .general, description: "Unknown error") +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/TaskFunctions.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/TaskFunctions.swift new file mode 100644 index 0000000..4e3f9bf --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/TaskFunctions.swift @@ -0,0 +1,735 @@ +// +// TaskFunctions.swift +// background_downloader +// +// Created on 2/11/23. +// + +import Foundation +import Flutter +import os.log + +let updatesQueue = DispatchQueue(label: "updatesProcessingQueue") +let separatorString = "***<<<|>>>***" + +/// True if this task expects to provide progress updates +func providesProgressUpdates(task: Task) -> Bool { + return task.updates == Updates.progressUpdates.rawValue || task.updates == Updates.statusChangeAndProgressUpdates.rawValue +} + +/// True if this task expects to provide status updates +func providesStatusUpdates(downloadTask: Task) -> Bool { + return downloadTask.updates == Updates.statusChange.rawValue || downloadTask.updates == Updates.statusChangeAndProgressUpdates.rawValue +} + +/// True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask +func isDownloadTask(task: Task) -> Bool { + return task.taskType == "DownloadTask" || task.taskType == "UriDownloadTask" || task.taskType == "ParallelDownloadTask" +} + +/// True if this task is a ParallelDownloadTask, false if not +func isParallelDownloadTask(task: Task) -> Bool +{ + return task.taskType == "ParallelDownloadTask" +} + +/// True if this task is an UploadTask, false if not +/// +/// A MultiUploadTask is also an UploadTask +func isUploadTask(task: Task) -> Bool { + return task.taskType == "UploadTask" || task.taskType == "UriUploadTask" || task.taskType == "MultiUploadTask" +} + +/// True if this task is a MultiUploadTask, false if not +func isMultiUploadTask(task: Task) -> Bool { + return task.taskType == "MultiUploadTask" +} + +/// True if this task is a binary UploadTask +func isBinaryUploadTask(task: Task) -> Bool { + return isUploadTask(task: task) && task.post?.lowercased() == "binary" +} + +/// True if this is a DataTask +func isDataTask(task: Task) -> Bool { + return task.taskType == "DataTask" +} + +/// True if this state is not a final state (i.e. more changes may happen) +func isNotFinalState(status: TaskStatus) -> Bool { + return status == .enqueued || status == .running || status == .waitingToRetry || status == .paused +} + +/// True if this state is a final state (i.e. no more changes will happen) +func isFinalState(status: TaskStatus) -> Bool { + return !isNotFinalState(status: status) +} + +/// Returns the absolute path to the file represented by this task +/// based on the [Task.filename] (default) or [withFilename] +/// +/// If the task is a MultiUploadTask and no [withFilename] is given, +/// returns the empty string, as there is no single path that can be +/// returned +func getFilePath(for task: Task, withFilename: String? = nil) -> String? { + if isMultiUploadTask(task: task) && withFilename == nil { + return "" + } + guard let directory = try? directoryForTask(task: task) + else { + return nil + } + return directory.appendingPath(withFilename ?? task.filename).path +} + +func stripFileExtension ( _ filename: String ) -> String { + var components = filename.components(separatedBy: ".") + guard components.count > 1 else { return filename } + components.removeLast() + return components.joined(separator: ".") +} + +/// Suggests a filename based on response headers and a URL. +/// If none can be derived, returns an empty string. +/// +/// - Parameters: +/// - responseHeaders: The response headers dictionary. +/// - urlString: The URL string the file would be downloaded from. +/// - Returns: A suggested filename, derived from the headers or the URL. +func suggestFilename(responseHeaders: [AnyHashable: Any], urlString: String) -> String { + do { + if let disposition = responseHeaders["Content-Disposition"] as? String ?? responseHeaders["content-disposition"] as? String { + // Try filename*=UTF-8'language'"encodedFilename" + let encodedFilenameRegEx = try NSRegularExpression(pattern: #"filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?"#, options: .caseInsensitive) + let range = NSRange(location: 0, length: disposition.utf16.count) + if let match = encodedFilenameRegEx.firstMatch(in: disposition, options: [], range: range) { + if let encodingRange = Range(match.range(at: 1), in: disposition), + let filenameRange = Range(match.range(at: 3), in: disposition) { + let encoding = String(disposition[encodingRange]).uppercased() + let filename = String(disposition[filenameRange]) + if encoding == "UTF-8" { + if let decodedFilename = filename.removingPercentEncoding { + return decodedFilename + } else { + os_log("Could not interpret suggested filename (UTF-8 url encoded) %@", log: log, type: .debug, filename) + } + } else { + return filename + } + } + } + + // Try filename="filename" + let plainFilenameRegEx = try NSRegularExpression(pattern: #"filename=\s*"?([^"]+)"?.*$"#, options: .caseInsensitive) + if let match = plainFilenameRegEx.firstMatch(in: disposition, options: [], range: range) { + if let filenameRange = Range(match.range(at: 1), in: disposition) { + return String(disposition[filenameRange]) + } + } + } + } catch { + os_log("Error parsing suggested filename from headers: %@", log: log, type: .error, error.localizedDescription) + } + + os_log("Could not determine suggested filename from server", log: log, type: .debug) + + // Try filename derived from last path segment of the url + if let url = URL(string: urlString), !url.lastPathComponent.isEmpty { + return url.lastPathComponent + } + + os_log("Could not parse URL pathSegment for suggested filename", log: log, type: .debug) + return "" // Default fallback +} + +/** + * Returns a copy of the task with the [Task.filename] property changed + * to the filename suggested by the server, or derived from the url, or + * unchanged. + * + * If [unique] is true, the filename is guaranteed not to already exist. This + * is accomplished by adding a suffix to the suggested filename with a number, + * e.g. "data (2).txt" + * + * The server-suggested filename is obtained from the [responseHeaders] entry + * "Content-Disposition" + */ +func taskWithSuggestedFilenameFromResponseHeaders( + task: Task, + responseHeaders: [AnyHashable: Any], + unique: Bool = false +) -> Task { + let suggestedFilenameValue = suggestFilename(responseHeaders: responseHeaders, urlString: task.url) + + if !suggestedFilenameValue.isEmpty { + return uniqueFilename(task: task.copyWith(filename: suggestedFilenameValue), unique: unique) + } + + // If everything fails, return the task with an unchanged filename + // except for possibly making it unique + return uniqueFilename(task: task, unique: unique) +} + +/// Returns [Task] with a filename similar to the one +/// supplied, but unused. +/// +/// If [unique], filename will sequence up in "filename (8).txt" format, +/// otherwise returns the [task] +func uniqueFilename(task: Task, unique: Bool) -> Task { + if !unique { + return task + } + let sequenceRegEx = try! NSRegularExpression(pattern: #"\((\d+)\)\.?[^.]*$"#) + let extensionRegEx = try! NSRegularExpression(pattern: #"\.[^.]*$"#) + var newTask = task + guard let filePath = getFilePath(for: task) else { + return task + } + var exists = FileManager.default.fileExists(atPath: filePath) + while exists { + let range = NSMakeRange(0, newTask.filename.utf16.count) + let extMatch = extensionRegEx.firstMatch(in: newTask.filename, options: [], range: range) + let extString = extMatch != nil ? String(newTask.filename[Range(extMatch!.range, in: newTask.filename)!]) : "" + let seqMatch = sequenceRegEx.firstMatch(in: newTask.filename, options: [], range: range) + let seqString = seqMatch != nil ? String(newTask.filename[Range(seqMatch!.range(at: 1), in: newTask.filename)!]) : "" + let newSequence = seqString.isEmpty ? 1 : Int(seqString)! + 1 + let newFilename: String + if seqMatch == nil { + let baseNameWithoutExtension = stripFileExtension(newTask.filename) + newFilename = "\(baseNameWithoutExtension) (\(newSequence))\(extString)" + } else { + let startOfSeq = seqMatch!.range.location + let index = newTask.filename.index(newTask.filename.startIndex, offsetBy: startOfSeq) + newFilename = "\(newTask.filename.prefix(upTo: index))(\(newSequence))\(extString)" + } + newTask = newTask.copyWith(filename: newFilename) + guard let filePath = getFilePath(for: newTask) else { + return task + } + exists = FileManager.default.fileExists(atPath: filePath) + } + return newTask +} + +/** + * Parses the range in a Range header, and returns a Pair representing + * the range. The format needs to be "bytes=10-20" + * + * A missing lower range is substituted with 0L, and a missing upper + * range with null. If the string cannot be parsed, returns (0L, null) + */ +func parseRange(rangeStr: String) -> (Int64, Int64?) { + let regex = try! NSRegularExpression(pattern: #"bytes=(\d*)-(\d*)"#) + let range = NSMakeRange(0, rangeStr.utf16.count) + if let match = regex.firstMatch(in: rangeStr, options: [], range: range) { + let start = Int64(String(rangeStr[Range(match.range(at: 1), in: rangeStr)!])) + let end = Int64(String(rangeStr[Range(match.range(at: 2), in: rangeStr)!])) + return (start!, end!) + } else { + return (0, nil) + } +} + +/// Returns the content length extracted from the [responseHeaders], or from +/// the [task] headers +func getContentLength(responseHeaders: [AnyHashable: Any], task: Task) -> Int64 { + // On iOS, the header has already been parsed for Content-Length so we don't need to + // repeat that here (actually, we use the bytesExpectedToSend which is for some reason + // not always set even whe a Content-Length is set) + // Try extracting content length from Range header + let taskRangeHeader = task.headers["Range"] ?? "" + let taskRange = parseRange(rangeStr: taskRangeHeader) + if let end = taskRange.1 { + let rangeLength = end - taskRange.0 + 1 + os_log("TaskId %@ contentLength set to %d based on Range header", log: log, type: .info, task.taskId, rangeLength) + return rangeLength + } + + // try extracting it from a special "Known-Content-Length" header + let knownLength = Int64(task.headers["Known-Content-Length"] ?? "-1") ?? -1 + if knownLength != -1 { + os_log("TaskId %@ contentLength set to %d based on Known-Content-Length header", log: log, type: .info, task.taskId, knownLength) + } else { + os_log("TaskId %@ contentLength undetermined", log: log, type: .info, task.taskId) + } + return knownLength +} + +/// Sets the mimeType and charSet extracted from the Content-Type header +/// in [responseHeaders] and stores in static maps keyed by [task.taskId] +func extractContentType(responseHeaders: [AnyHashable: Any], task: Task) { + guard let contentType = responseHeaders["Content-Type"] as? String else { return } + let regEx = try! NSRegularExpression(pattern: #"(.*);\s*charset\s*=(.*)"#) + let range = NSMakeRange(0, contentType.utf16.count) + let match = regEx.firstMatch(in: contentType, options: [], range: range) + BDPlugin.propertyLock.withLock({ + if let match = match { + let mimeType = String(contentType[Range(match.range(at: 1), in: contentType)!]) + let charSet = String(contentType[Range(match.range(at: 2), in: contentType)!]) + BDPlugin.mimeTypes[task.taskId] = mimeType + BDPlugin.charSets[task.taskId] = charSet + } else { + BDPlugin.mimeTypes[task.taskId] = contentType + } + }) +} + + + +/// Returns a list of fileData elements, one for each file to upload. +/// Each element is a triple containing fileField, full filePath, mimeType +/// +/// The lists are stored in the similarly named String fields as a JSON list, +/// with each list the same length. For the filenames list, if a filename refers +/// to a file that exists (i.e. it is a full path) then that is the filePath used, +/// otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory] +/// to form a full file path +func extractFilesData(task: Task) -> [((String, String, String))] { + let decoder = JSONDecoder() + guard + let fileFields = try? decoder.decode([String].self, from: task.fileField!.data(using: .utf8)!), + let filenames = try? decoder.decode([String].self, from: task.filename.data(using: .utf8)!), + let mimeTypes = try? decoder.decode([String].self, from: task.mimeType!.data(using: .utf8)!) + else { + os_log("Could not parse filesData from field=%@, filename=%@ and mimeType=%@", log: log, type: .error, task.fileField!, task.filename, task.mimeType!) + return [] + } + var result = [(String, String, String)]() + for i in 0 ..< fileFields.count { + let fileUrl = URL(string: filenames[i]) + let decodedFileUrl = fileUrl != nil ? decodeToFileUrl(uri: fileUrl!) : nil + if decodedFileUrl?.scheme == "file" { + // URI mode, return path + result.append((fileFields[i], decodedFileUrl!.path, mimeTypes[i])) + } else { + // file path mode + if FileManager.default.fileExists(atPath: filenames[i]) { + result.append((fileFields[i], filenames[i], mimeTypes[i])) + } else { + result.append(( + fileFields[i], + getFilePath(for: task, withFilename: filenames[i]) ?? "", + mimeTypes[i] + )) + } + } + } + return result +} + +/// Return the host name for the task's url or "" +func getHost(_ task: Task) -> String { + return URL(string: task.url)?.host ?? "" +} + +/// Calculate progress, network speed and time remaining, and send this at an appropriate +/// interval to the Dart side +func updateProgress(task: Task, totalBytesExpected: Int64, totalBytesDone: Int64) { + let info = BDPlugin.propertyLock.withLock({ + return BDPlugin.progressInfo[task.taskId] ?? (lastProgressUpdateTime: 0.0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0) + }) + let now = Date().timeIntervalSince1970 + if totalBytesExpected != NSURLSessionTransferSizeUnknown && now > info.lastProgressUpdateTime + 0.5 { + let progress = min(Double(totalBytesDone) / Double(totalBytesExpected), 0.999) + if (progress - info.lastProgressValue > 0.02) || (progress > info.lastProgressValue && now > info.lastProgressUpdateTime + 2.5) { + // calculate network speed and time remaining + let now = Date().timeIntervalSince1970 + let timeSinceLastUpdate = now - info.lastProgressUpdateTime + let bytesSinceLastUpdate = totalBytesDone - info.lastTotalBytesDone + let currentNetworkSpeed: Double = timeSinceLastUpdate > 3600 ? -1.0 : Double(bytesSinceLastUpdate) / timeSinceLastUpdate / 1000000.0 + let newNetworkSpeed = info.lastNetworkSpeed == -1.0 ? currentNetworkSpeed : (info.lastNetworkSpeed * 3.0 + currentNetworkSpeed) / 4.0 + let remainingBytes = (1.0 - progress) * Double(totalBytesExpected) + let timeRemaining: TimeInterval = newNetworkSpeed == -1.0 ? -1.0 : (remainingBytes / newNetworkSpeed / 1000000.0) + BDPlugin.propertyLock.withLock({ + BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: now, lastProgressValue: progress, lastTotalBytesDone: totalBytesDone, lastNetworkSpeed: newNetworkSpeed) + }) + processProgressUpdate(task: task, progress: progress, expectedFileSize: totalBytesExpected, networkSpeed: newNetworkSpeed, timeRemaining: timeRemaining) + } + } +} + + +/// Processes a change in status for the task +/// +/// Sends status update via the background channel to Dart, if requested +/// If the task is finished, processes a final progressUpdate update and removes +/// task from persistent storage +func processStatusUpdate(task: Task, status: TaskStatus, taskException: TaskException? = nil, responseBody: String? = nil, responseHeaders: [AnyHashable:Any]? = nil, responseStatusCode: Int? = nil, mimeType: String? = nil, charSet: String? = nil) { + // Intercept status updates resulting from re-enqueue requests, which + // themselves are triggered by a change in WiFi requirement + let intercepted = BDPlugin.propertyLock.withLock { + if BDPlugin.tasksToReEnqueue.remove(task) != nil { + defer { + if BDPlugin.tasksToReEnqueue.isEmpty { + WiFiQueue.shared.reEnqueue(nil) // signal end of batch + } + } + if [TaskStatus.paused, TaskStatus.canceled, TaskStatus.failed].contains(status) { + _ = BDPlugin.progressInfo.removeValue(forKey: task.taskId) // ensure .running update on re-enqueue + BDPlugin.initialResponseDataProcessed.remove(task.taskId) + let resumeDataAsBase64String = status == .paused ? (BDPlugin.localResumeData[task.taskId] ?? "") : "" + if status != .paused { + BDPlugin.localResumeData.removeValue(forKey: task.taskId) + } + let reEnqueueData = EnqueueItem(task: task, notificationConfigJsonString: BDPlugin.notificationConfigJsonStrings[task.taskId], resumeDataAsBase64String: resumeDataAsBase64String) + WiFiQueue.shared.reEnqueue(reEnqueueData) + return true // intercepted + } + } + return false // not intercepted + } + if intercepted { + return + } + + // Normal status update + + // Post update if task expects one, or if failed and retry is needed + let retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0 + // if task is in final state, process a final progressUpdate + // A 'failed' progress update is only provided if + // a retry is not needed: if it is needed, a `waitingToRetry` progress update + // will be generated on the Dart side + switch (status) { + case .complete: + processProgressUpdate(task: task, progress: 1.0) + case .failed: + if !retryNeeded { + processProgressUpdate(task: task, progress: -1.0) + } + case .canceled: + processProgressUpdate(task: task, progress: -2.0) + case .notFound: + processProgressUpdate(task: task, progress: -3.0) + case .paused: + processProgressUpdate(task: task, progress: -5.0) + default: + break + } + // determine the TaskStatusUpdate + let finalResponseStatusCode = status == .complete || status == .notFound + ? responseStatusCode + : nil + let finalTaskException = taskException == nil + ? TaskException(type: .general, httpResponseCode: -1, description: "") + : taskException + let statusUpdate = isFinalState(status: status) + ? TaskStatusUpdate(task: task, + taskStatus: status, + exception: status == .failed ? finalTaskException : nil, + responseBody: responseBody, + responseStatusCode: (status == .complete || status == .notFound) ? finalResponseStatusCode : nil, + responseHeaders: lowerCasedStringStringMap(responseHeaders), + mimeType: mimeType, + charSet: charSet) + : TaskStatusUpdate(task: task, taskStatus: status) + if let onNativeTaskStatusChange = BDPlugin.onNativeTaskStatusChange { + onNativeTaskStatusChange(task, statusUpdate) + } + if providesStatusUpdates(downloadTask: task) || retryNeeded { + let arg = statusUpdate.argList() + if !postOnBackgroundChannel(method: "statusUpdate", task: task, arg: arg) { + // store update locally as a merged task/status JSON string, without error info + guard let jsonData = try? JSONEncoder().encode(statusUpdate) + else { + os_log("Could not store status update locally", log: log, type: .debug) + return } + storeLocally(prefsKey: BDPlugin.keyStatusUpdateMap, taskId: task.taskId, item: jsonData) + } + } + if isFinalState(status: status) { + // remove references to this task that are no longer needed + BDPlugin.propertyLock.withLock { + BDPlugin.progressInfo.removeValue(forKey: task.taskId) + BDPlugin.localResumeData.removeValue(forKey: task.taskId) + BDPlugin.remainingBytesToDownload.removeValue(forKey: task.taskId) + BDPlugin.tasksWithModifications.removeValue(forKey: task.taskId) + BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: task.taskId) + BDPlugin.responseBodyData.removeValue(forKey: task.taskId) + BDPlugin.notificationConfigJsonStrings.removeValue(forKey: task.taskId) + BDPlugin.taskIdsThatCanResume.remove(task.taskId) + BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(task.taskId) + } + // invoke onTaskFinished callback if needed + if task.options?.hasOnFinishedCallback() == true { + _Concurrency.Task { + if !(await invokeOnTaskFinishedCallback(taskStatusUpdate: statusUpdate)) { + os_log("Could not invoke onTaskFinishedCallback", log: log, type: .error) + } + } + } + } +} + + +/// Processes a progress update for the task +/// +/// Sends progress update via the background channel to Dart, if requested +func processProgressUpdate(task: Task, progress: Double, expectedFileSize: Int64 = -1, networkSpeed: Double = -1.0, timeRemaining: TimeInterval = -1.0) { + if let onNativeTaskProgressChange = BDPlugin.onNativeTaskProgressChange { + onNativeTaskProgressChange(task, progress) + } + if providesProgressUpdates(task: task) { + if (!postOnBackgroundChannel(method: "progressUpdate", task: task, arg: [progress, expectedFileSize, networkSpeed, Int(timeRemaining * 1000.0)] as [Any])) { + // store update locally as a merged task/progress JSON string + guard let jsonData = try? JSONEncoder().encode(TaskProgressUpdate(task: task, progress: progress, expectedFileSize: expectedFileSize)) + else { + os_log("Could not store progress update locally", log: log, type: .info) + return } + storeLocally(prefsKey: BDPlugin.keyProgressUpdateMap, taskId: task.taskId, item: jsonData) + } + } +} + +/// Process a 'canResume' message for the task +/// +/// Sends the data via the background channel to Dart +func processCanResume(task: Task, taskCanResume: Bool) { + if !postOnBackgroundChannel(method: "canResume", task: task, arg: taskCanResume) { + os_log("Could not post CanResume", log: log, type: .info) + } +} + +/// Post resume data for this task +/// +/// Returns true if successful. +/// Sends the data via the background channel to Dart +func processResumeData(task: Task, resumeData: Data) -> Bool { + let resumeDataAsBase64String = resumeData.base64EncodedString() + BDPlugin.propertyLock.withLock { + BDPlugin.localResumeData[task.taskId] = resumeDataAsBase64String + } + if !postOnBackgroundChannel(method: "resumeData", task: task, arg: resumeDataAsBase64String) { + // store resume data locally + guard let jsonData = try? JSONEncoder().encode(ResumeData(task: task, data: resumeDataAsBase64String)) + else { + os_log("Could not store resume data locally", log: log, type: .info) + return false} + storeLocally(prefsKey: BDPlugin.keyResumeDataMap, taskId: task.taskId, item: jsonData) + } + return true +} + +/// Return the background channel for cummincation to Dart side, or nil +func getBackgroundChannel() -> FlutterMethodChannel? { + guard let channel = BDPlugin.backgroundChannel else { + os_log("Could not find background channel", log: log, type: .error) + return nil + } + return channel +} + +/// Post method message on backgroundChannel with arguments and return true if this was successful +/// +/// [arg] can be a list or a single variable +func postOnBackgroundChannel(method: String, task:Task, arg: Any) -> Bool { + guard let channel = BDPlugin.backgroundChannel else { + os_log("Could not find background channel", log: log, type: .error) + return false + } + guard let jsonString = jsonStringFor(task: task) else { + os_log("Could not convert task to JSON", log: log, type: .error) + return false + } + var argsList: [Any?] = [jsonString] + if arg is [Any?] { + argsList.append(contentsOf: arg as! [Any?]) + } else { + argsList.append(arg) + } + if Thread.isMainThread { + DispatchQueue.main.async { + channel.invokeMethod(method, arguments: argsList) + } + return true + } + var success = false + updatesQueue.sync { + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() + DispatchQueue.main.async { + channel.invokeMethod(method, arguments: argsList, result: {(r: Any?) -> () in + success = !(r is FlutterError) + if BDPlugin.forceFailPostOnBackgroundChannel { + success = false + } + dispatchGroup.leave() + }) + } + dispatchGroup.wait() + } + return success +} + +/// Store the [item] in preferences under [prefsKey], keyed by [taskId] +/// +/// [item] is a JsonEncoded Data object +func storeLocally(prefsKey: String, taskId: String, + item: Data) { + let defaults = UserDefaults.standard + var map = defaults.dictionary(forKey: prefsKey) ?? [:] + map[taskId] = String(data: item, encoding: .utf8) + defaults.set(map, forKey: prefsKey) +} + +/// Returns a JSON string for this Task, or nil +func jsonStringFor(task: Task) -> String? { + let jsonEncoder = JSONEncoder() + guard let jsonResultData = try? jsonEncoder.encode(task) + else { + return nil + } + return String(data: jsonResultData, encoding: .utf8) +} + +/// Returns a JSON string for this TaskStatusUpdate, or nil +func jsonStringFor(taskStatusUpdate: TaskStatusUpdate) -> String? { + let jsonEncoder = JSONEncoder() + guard let jsonResultData = try? jsonEncoder.encode(taskStatusUpdate) + else { + return nil + } + return String(data: jsonResultData, encoding: .utf8) +} + +/// Returns a Task from the supplied jsonString, or nil +func taskFrom(jsonString: String) -> Task? { + let decoder = JSONDecoder() + let task: Task? = try? decoder.decode(Task.self, from: (jsonString).data(using: .utf8)!) + return task +} + +/// Return the task corresponding to the URLSessionTask, or nil if it cannot be matched +/// +/// May return a modified version of the task instead of the original stored with the URLSessionTask, e.g. +/// if the filename has changed +func getTaskFrom(urlSessionTask: URLSessionTask) -> Task? { + guard let jsonData = getTaskJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8) + else { + return nil + } + let decoder = JSONDecoder() + if let task = try? decoder.decode(Task.self, from: jsonData) { + let modifiedTask = BDPlugin.propertyLock.withLock({ + BDPlugin.tasksWithModifications[task.taskId] + }) + return modifiedTask ?? task + } + return nil +} + +/// Marks the task as modified such that [getTaskFrom] a UrlSession wlll get the modified task, not the one' +/// immutably stored with the urlSession +func storeModifiedTask(task: Task) { + BDPlugin.propertyLock.withLock({ + BDPlugin.tasksWithModifications[task.taskId] = task + }) +} + +/// Returns the taskJsonString contained in the urlSessionTask +func getTaskJsonStringFrom(urlSessionTask: URLSessionTask) -> String? { + guard let taskDescription = urlSessionTask.taskDescription else { + return nil + } + if taskDescription.contains(separatorString) { + return taskDescription.components(separatedBy: separatorString)[0] + } + return taskDescription +} + +/// Return the notificationConfig corresponding to the URLSessionTask, or nil if it cannot be matched +func getNotificationConfigFrom(urlSessionTask: URLSessionTask) -> NotificationConfig? { + guard let jsonData = getNotificationConfigJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8) + else { + return nil + } + let decoder = JSONDecoder() + return try? decoder.decode(NotificationConfig.self, from: jsonData) +} + + +/// Returns the notificationConfigJsonString contained in the urlSessionTask +func getNotificationConfigJsonStringFrom(urlSessionTask: URLSessionTask) -> String? { + guard let taskDescription = urlSessionTask.taskDescription else { + return nil + } + if taskDescription.contains(separatorString) { + return taskDescription.components(separatedBy: separatorString)[1] + } + return nil +} + +/// Returns the URL of the directory where the file for this task is stored +/// +/// This is made up of the baseDirectory and the directory fields of the Task +func directoryForTask(task: Task) throws -> URL { + let documentsURL: URL + if task.baseDirectory != BaseDirectory.root.rawValue { + var dir: FileManager.SearchPathDirectory + switch task.baseDirectory { + case 0: + dir = .documentDirectory + case 1: + dir = .cachesDirectory + case 2: + dir = .applicationSupportDirectory + case 3: + dir = .libraryDirectory + default: + dir = .documentDirectory + } + documentsURL = + try FileManager.default.url(for: dir, + in: .userDomainMask, + appropriateFor: nil, + create: true) + } else { + documentsURL = URL(fileURLWithPath: "/") + } + return task.directory.isEmpty + ? documentsURL + : documentsURL.appendingPath(task.directory, isDirectory: true) + +} + +/// True if task requires WiFi, based on global and task-specific setting +func taskRequiresWiFi(task: Task) -> Bool { + return BDPlugin.requireWiFi == .forAllTasks || (BDPlugin.requireWiFi == .asSetByTask && task.requiresWiFi) +} + +/** + * Returns true if there is insufficient space to store a file of length + * [contentLength] + * + * Returns false if [contentLength] <= 0 + * Returns false if configCheckAvailableSpace has not been set, or if available + * space is greater than that setting + * Returns true otherwise + */ +func insufficientSpace(contentLength: Int64) -> Bool { + guard contentLength > 0 else { + return false + } + let checkValue = UserDefaults.standard.integer(forKey: BDPlugin.keyConfigCheckAvailableSpace) + guard + // Check if the configCheckAvailableSpace preference is set and is positive + checkValue > 0, + let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first, + let available = try? URL(fileURLWithPath: path).resourceValues(forKeys: [URLResourceKey.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage + else { + return false + } + // Calculate the total remaining bytes to download + let remainingBytesToDownload = BDPlugin.propertyLock.withLock( { + BDPlugin.remainingBytesToDownload.values.reduce(0, +) + }) + // Return true if there is insufficient space to store the file + return available - (remainingBytesToDownload + contentLength) < checkValue << 20 +} + +/// Post result [value] on FlutterResult completer +func postResult(result: FlutterResult?, value: Any) { + if result != nil { + result!(value) + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Uploader.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Uploader.swift new file mode 100644 index 0000000..1d4565a --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/Uploader.swift @@ -0,0 +1,257 @@ +// +// Uploader.swift +// background_downloader +// +// Created on 2/11/23. +// + +import Foundation +import os.log + +enum StreamError: Error { + case Closed +} + + + +/// Uploader associated with one URLSessionUploadTask +public class Uploader : NSObject, URLSessionTaskDelegate, StreamDelegate { + + var task: Task + let outputFilename: String + var contentDispositionString = "" + var contentTypeString = "" + var fieldsString = "" + var totalBytesWritten: Int64 = 0 + static let boundary = "-----background_downloader-akjhfw281onqciyhnIk" + let lineFeed = "\r\n" + let asciiOnly = try! NSRegularExpression(pattern: "^[\\x00-\\x7F]+$") + let jsonString = try! NSRegularExpression(pattern: "^\\s*(\\{.*\\}|\\[.*\\])\\s*$") + let newlineRegExp = try! NSRegularExpression(pattern: "\r\n|\r|\n") + let bufferSize = 2 << 13 + + + /// Initialize an Uploader for this Task + init(task: Task) { + self.task = task + outputFilename = NSUUID().uuidString + } + + + /// Creates the multipart file so it can be uploaded + /// + /// Returns true if successful, false otherwise (cleaning up any partial file on failure). + public func createMultipartFile() -> Bool { + if writeMultipartFile() { + return true + } + try? FileManager.default.removeItem(at: outputFileUrl()) + return false + } + + /// Writes the multipart file, returning true if successful + private func writeMultipartFile() -> Bool { + // create the output file + FileManager.default.createFile(atPath: outputFileUrl().path, contents:Data(" ".utf8), attributes: nil) + guard let fileHandle = try? FileHandle(forWritingTo: outputFileUrl()) else { + os_log("Could not open temporary file %@", log: log, type: .error, outputFileUrl().path) + return false + } + // field portion of the multipart, all in one string + // multiple values should be encoded as '"value1", "value2", ...' + let multiValueRegEx = try! NSRegularExpression(pattern: #"^(?:"[^"]+"\s*,\s*)+"[^"]+"$"#, options: []) + for entry in task.fields ?? [:] { + let value = entry.value + let fullRange = NSRange(location: 0, length: value.utf16.count) + // Check if the value matches the multi-value format + if multiValueRegEx.firstMatch(in: value, options: [], range: fullRange) != nil { + // Extract multiple values from entry.value + let valueMatchRegEx = try! NSRegularExpression(pattern: #""([^"]+)""#, options: []) + valueMatchRegEx.enumerateMatches(in: value, options: [], range: fullRange) { match, _, _ in + if let matchRange = match?.range(at: 1), // Capture group 1 + let swiftRange = Range(matchRange, in: value) { + let matchedString = String(value[swiftRange]) + fieldsString += fieldEntry(name: entry.key, value: matchedString) + } + } + } else { + // Handle single value for key + fieldsString += fieldEntry(name: entry.key, value: entry.value) + } + } + if (!writeFields(fileHandle: fileHandle)) { + os_log("Could not write to temporary file %@", log: log, type: .error, outputFileUrl().path) + return false + } + // File portion of the multi-part + // Assumes list of files. If only one file, that becomes a list of length one. + // For each file, determine contentDispositionString, contentTypeString + // and file length, so that we can calculate total size of upload + let separator = "\(lineFeed)--\(Uploader.boundary)\(lineFeed)" // between files + let terminator = "\(lineFeed)--\(Uploader.boundary)--\(lineFeed)" // after last file + let maybeFileUri = uriFromStringValue(maybePacked: task.filename) + let maybeDecodedFileUri = maybeFileUri != nil ? decodeToFileUrl(uri: maybeFileUri!) : nil + guard let filePath = getFilePath(for: task) else {return false} + let filesData = filePath.isEmpty + ? extractFilesData(task: task) // MultiUpload case + : [(task.fileField!,maybeDecodedFileUri?.path ?? filePath, task.mimeType!)] // one file Upload case + for (fileField, path, mimeType) in filesData { + if !FileManager.default.fileExists(atPath: path) { + os_log("File to upload does not exist at %@", log: log, type: .error, path) + return false + } + let derivedFilename = path.components(separatedBy: "/").last! + if filesData.count == 1 && task.taskType != "MultiUploadTask" { + // only for single file uploads do we set the task's filename property + let newTask = task.copyWith(filename: maybeFileUri != nil + ? pack(filename: derivedFilename, uri: maybeFileUri!) + : derivedFilename) + storeModifiedTask(task: newTask) + } + let contentDispositionString = + "Content-Disposition: form-data; name=\"\(browserEncode(fileField))\"; " + + "filename=\"\(browserEncode(derivedFilename))\"\(lineFeed)" + let resolvedMimeType = mimeType.isEmpty ? getMimeType(fromFilename: path) : mimeType + let contentTypeString = "Content-Type: \(resolvedMimeType)\(lineFeed)\(lineFeed)" + let fileUrl = URL(fileURLWithPath: path) + guard let inputStream = InputStream(url: fileUrl) else { + os_log("Could not open file to upload at %@", log: log, type: .error, path) + return false + } + if (!writeFileBytes(fileHandle: fileHandle, inputStream: inputStream, contentDisposition: contentDispositionString, contentType: contentTypeString)) { + os_log("Could not upload file at %@", log: log, type: .error, path) + return false + } + if (!writeText(fileHandle: fileHandle, text: path == filesData.last!.1 ? + terminator : separator)) { + os_log("Could not write separator for file at %@", log: log, type: .error, path) + return false + } + } + return true + } + + /// Return the URL of the generated outputfile with multipart data + /// + /// Callers outside this class should only use this after calling + /// createMultipartFile, and only when that returns true + func outputFileUrl() -> URL { + return FileManager.default.temporaryDirectory.appendingPath(outputFilename) + } + + /// Write form fields + /// + /// Returns true if successful, false otherwise + private func writeFields(fileHandle: FileHandle) -> Bool { + // construct the preamble + return writeText(fileHandle: fileHandle, text: "\(fieldsString)--\(Uploader.boundary)\(lineFeed)") + } + + /// Write the data bytes from the provided inputStream to the fileHandle + /// + /// Retruns true if successful, false otherwise + private func writeFileBytes(fileHandle: FileHandle, inputStream: InputStream, contentDisposition: String, contentType: String) -> Bool { + // multipart file header + if !(writeText(fileHandle: fileHandle, text: contentDisposition) && + writeText(fileHandle: fileHandle, text: contentType)) { + return false + } + // file bytes + inputStream.open() + while inputStream.hasBytesAvailable { + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { + buffer.deallocate() + } + let bytesRead = inputStream.read(buffer, maxLength: bufferSize) + if bytesRead < 0 { + // Stream error occured + os_log("Error reading from file for taskId %@", log: log, type: .error, task.taskId) + inputStream.close() + return false + } else if bytesRead == 0 { + inputStream.close() + return true + } + let data = Data.init(bytesNoCopy: buffer, count: bytesRead, deallocator: .none) + do { + try fileHandle.write(contentsOf: data) + } catch { + os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription) + inputStream.close() + return false + } + } + inputStream.close() + return true + } + + /// Write text to the [fileHandle] and return true if successful + private func writeText(fileHandle: FileHandle, text: String) -> Bool { + guard let epilogue = text.data(using: .utf8) else { + return false + } + do { + try fileHandle.write(contentsOf: epilogue) + } catch { + os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription) + return false + } + totalBytesWritten += Int64(epilogue.count) + return true + } + + + /// Returns the multipart entry for one field name/value pair + private func fieldEntry(name: String, value: String) -> String { + return "--\(Uploader.boundary)\(lineFeed)\(headerForField(name: name, value: value))\(value)\(lineFeed)" + } + + /// Returns the header string for a field + /// + /// The return value is guaranteed to contain only ASCII characters + private func headerForField(name: String, value: String) -> String { + var header = "Content-Disposition: form-data; name=\"\(browserEncode(name))\"" + if isJsonString(value) { + header = "\(header)\r\n" + + "Content-Type: application/json; charset=utf-8" + } else if !isPlainAscii(value) { + header = "\(header)\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: binary" + } + return "\(header)\r\n\r\n" + } + + + /// Returns whether [string] is composed entirely of ASCII-compatible characters + private func isPlainAscii(_ string: String)-> Bool { + let result = asciiOnly.matches(in: string, + range: NSMakeRange(0, (string as NSString).length)) + return !result.isEmpty + } + + /// Returns whether [string] is a JSON formatted string + private func isJsonString(_ string: String)-> Bool { + let result = jsonString.matches(in: string, + range: NSMakeRange(0, (string as NSString).length)) + return !result.isEmpty + } + + /// Encode [value] in the same way browsers do + private func browserEncode(_ value: String)-> String { + // http://tools.ietf.org/html/rfc2388 mandates some complex encodings for + // field names and file names, but in practice user agents seem not to + // follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as + // `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII + // characters). We follow their behavior. + let newlinesReplaced = newlineRegExp.stringByReplacingMatches( + in: value, + options: [], + range: NSMakeRange(0, (value as NSString).length), withTemplate: "%0D%0A") + return newlinesReplaced.replacingOccurrences(of: "\"", with: "%22") + } + +} + + diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/UriUtils.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/UriUtils.swift new file mode 100644 index 0000000..6893538 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/UriUtils.swift @@ -0,0 +1,762 @@ +// +// UriUtils.swift +// background_downloader +// +// Created by Bram on 1/18/25. +// + +import Flutter +import UIKit +import UniformTypeIdentifiers +import MobileCoreServices +import PhotosUI +import os.log + + + +public class UriUtilsMethodCallHelper: NSObject, + FlutterPlugin, + UIDocumentPickerDelegate, + PHPickerViewControllerDelegate { + private var flutterResult: FlutterResult? + private var persistedUriPermission: Bool = false + private var multipleAllowed: Bool = false + private var allowVideos = false + private var localFileUrls: [String] = [] // for media picker + private var accessedSecurityScopedUrls: Set = Set() + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "com.bbflight.background_downloader.uriutils", binaryMessenger: registrar.messenger()) + let instance = UriUtilsMethodCallHelper() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "pickDirectory": + pickDirectory(call, result: result) + case "pickFiles": + pickFiles(call, result: result) + case "createDirectory": + createDirectory(call, result: result) + case "activateUri": + activateUri(call, result: result) + case "getFileBytes": + getFileBytes(call, result: result) + case "copyFile": + copyFile(call, result: result) + case "moveFile": + moveFile(call, result: result) + case "deleteFile": + deleteFile(call, result: result) + case "openFile": + openFile(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func pickDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let persistedUriPermission = args[2] as? Bool else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickDirectory", details: nil)) + return + } + + self.persistedUriPermission = persistedUriPermission + self.flutterResult = result + + var startLocation: URL? = nil + + if let startLocationUriString = args[1] as? String, + let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) { + startLocation = startLocationUri + } else if let startLocationOrdinal = args[0] as? Int, + let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) { + startLocation = getInitialDirectoryUrl(location: sharedStorage) + } + + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder], asCopy: false) + documentPicker.delegate = self + documentPicker.allowsMultipleSelection = false + + // Set the initial directory if available + if #available(iOS 14.0, *), let startLocation = startLocation { + documentPicker.directoryURL = startLocation + } + + // Present the document picker + if let rootViewController = getRootViewController() { + rootViewController.present(documentPicker, animated: true, completion: nil) + } else { + completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil)) + } + } + + + private func pickFiles(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let multipleAllowed = args[3] as? Bool, + let persistedUriPermission = args[4] as? Bool else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickFiles", details: nil)) + return + } + + self.persistedUriPermission = persistedUriPermission + self.multipleAllowed = multipleAllowed + self.flutterResult = result + + var startLocation: URL? = nil + + if let startLocationUriString = args[1] as? String, + let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) { + startLocation = startLocationUri + } else if let startLocationOrdinal = args[0] as? Int, + let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) { + if sharedStorage == .images || sharedStorage == .video { + pickMedia(startLocation: sharedStorage) + return + } + startLocation = getInitialDirectoryUrl(location: sharedStorage) + } + + // Convert allowed extensions to UTTypes + var allowedContentTypes: [UTType] = [] + if let allowedExtensions = args[2] as? [String] { + for ext in allowedExtensions { + if let utType = UTType(filenameExtension: ext) { + allowedContentTypes.append(utType) + } + } + } + if allowedContentTypes.isEmpty { + allowedContentTypes = [UTType.item] // Allow all types if no valid extensions are provided + } + + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: allowedContentTypes, asCopy: false) + documentPicker.delegate = self + documentPicker.allowsMultipleSelection = multipleAllowed + + // Set the initial directory if available + if let startLocation = startLocation { + documentPicker.directoryURL = startLocation + } + + // Present the document picker + if let rootViewController = UIApplication.shared.keyWindow?.rootViewController { + rootViewController.present(documentPicker, animated: true, completion: nil) + } else { + completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil)) + } + } + + /** + * Launches the photo picker to select one or more media files (images or videos). + * + * Called only from [pickFiles] where argument + * parsing has already happened + * + * @param startLocation The shared storage location to start the picker in (images or video). + * + * The result posted back is either a list of URIs as a String, or null (if the user has + * cancelled the picker) or an error + */ + private func pickMedia(startLocation: SharedStorage) { + var configuration = PHPickerConfiguration() + configuration.filter = startLocation == .images ? .images : .videos + configuration.selectionLimit = multipleAllowed ? 0 : 1 // 0 means unlimited + let pickerViewController = PHPickerViewController(configuration: configuration) + pickerViewController.delegate = self + + if let rootViewController = getRootViewController() { + rootViewController.present(pickerViewController, animated: true, completion: nil) + } else { + completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil)) + } + } + + + /** + * Creates a new directory at the specified path within the given parent directory URI. + * The parent directory URI must be resolvable to a file:// URI. Supports creating intermediate directories. + * + * - Parameters: + * - call: The Flutter method call. The arguments are expected to be a list + * containing: the parent directory URI (String), the new directory name (String), + * and a boolean indicating whether to request persisted URI permission. + * - result: The Flutter result callback. On success, the URI of the newly + * created directory (String) is returned. On failure, a `FlutterError` is returned. + */ + private func createDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let parentDirectoryUriString = args[0] as? String, + let newDirectoryName = args[1] as? String, + let persistedUriPermission = args[2] as? Bool, + let parentDirectoryUri = decodeToFileUrl(uriString: parentDirectoryUriString) + else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for createDirectory", details: nil)) + return + } + + do { + let newDirectoryURL = parentDirectoryUri.appendingPathComponent(newDirectoryName, isDirectory: true) + + // Ensure we have access to the parent directory before attempting to create a subdirectory. + if !parentDirectoryUri.startAccessingSecurityScopedResource() { + result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access parent directory: \(parentDirectoryUri)", details: nil)) + return + } + accessedSecurityScopedUrls.insert(parentDirectoryUri) + + // Create the directory + try FileManager.default.createDirectory(at: newDirectoryURL, withIntermediateDirectories: true, attributes: nil) + + // If persistence is requested, we will also need to stop accessing and deallocate the URL when done. + if persistedUriPermission { + accessedSecurityScopedUrls.insert(newDirectoryURL) + // Return the persisted URI (bookmark) + let bookmarkUri = try createBookmarkUriFromUrl(fileURL: newDirectoryURL) + result(bookmarkUri.absoluteString) + } else { + // Return the regular file URI + result(newDirectoryURL.absoluteString) + } + + } catch { + result(FlutterError(code: "CREATE_DIRECTORY_FAILED", message: "Failed to create directory: \(error.localizedDescription)", details: nil)) + } + } + + /** + * Activates a previously accessed directory or file (represented by a URI string) + * by calling `startAccessingSecurityScopedResource`. This is necessary on iOS + * to regain access to resources outside the app's sandbox that were previously + * granted access (e.g., through a document picker). Also decodes `urlbookmark://` + * and `media://` URIs to `file://` URIs. + * + * - Parameters: + * - call: The Flutter method call. The argument is expected to be the URI string. + * - result: The Flutter result callback. On success, the (potentially decoded) + * `file://` URI string is returned. On failure (e.g., invalid URI, access denied), + * a `FlutterError` is returned. If the URI is decoded, it is also added to + * `accessedSecurityScopedUrls` + */ + private func activateUri(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let uriString = call.arguments as? String, + let uri = decodeToFileUrl(uriString: uriString) + else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for activateUri", details: nil)) + return + } + // Start accessing the security-scoped resource. + if !uri.startAccessingSecurityScopedResource() { + completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "activateUri failed to access security-scoped resource: \(uri)", details: nil)) + return + } + accessedSecurityScopedUrls.insert(uri) + result(uri.absoluteString) + } + + /** + * Retrieves the file data (bytes) for a given URI string. + * + * - Parameters: + * - call: The Flutter method call containing the arguments. The argument is + * expected to be the URI string. This string will be decoded using `decodeToFileUrl`. + * - result: The Flutter result callback to be invoked with the resul---t. On success + * the file data as a `FlutterStandardTypedData` is returned. On failure, + * a `FlutterError` is returned. + */ + private func getFileBytes(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let uriString = call.arguments as? String, + let fileUrl = decodeToFileUrl(uriString: uriString) else { + result(FlutterError(code: "INVALID_URI", message: "Invalid or unresolvable URI: \(call.arguments)", details: nil)) + return + } + + // If this is a bookmark URI, we need to start accessing the security-scoped resource. + if uriString.starts(with: "urlbookmark://") { + if !fileUrl.startAccessingSecurityScopedResource() { + result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(fileUrl)", details: nil)) + return + } + accessedSecurityScopedUrls.insert(fileUrl) + } + + do { + let fileData = try Data(contentsOf: fileUrl) + result(FlutterStandardTypedData(bytes: fileData)) + } catch { + result(FlutterError(code: "FILE_READ_ERROR", message: "Failed to read file data: \(error.localizedDescription)", details: nil)) + } + } + + /** + * Copies the file at the given source URI to the destination URI. + * Both the source and destination URIs must be resolvable to file:// URIs. + * + * - Parameters: + * - call: The Flutter method call containing the arguments. The arguments + * are expected to be a list containing two strings: the source URI and + * the destination URI. + * - result: The Flutter result callback to be invoked with the result + * of the copy operation. On success, the destination URI string is + * returned. On failure, a `FlutterError` is returned. + */ + private func copyFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let sourceUriString = args[0] as? String, + let destinationUriString = args[1] as? String, + let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode to file URL + let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode to file URL + else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for copyFile", details: nil)) + return + } + do { + // Ensure destination directory exists + let destinationDirectory = destinationUrl.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: destinationDirectory.path) { + try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil) + } + try FileManager.default.copyItem(at: sourceUrl, to: destinationUrl) + result(destinationUrl.absoluteString) + } catch { + result(FlutterError(code: "COPY_FAILED", message: "Failed to copy file: \(error.localizedDescription)", details: nil)) + } + } + + /** + * Moves the file at the given source URI to the destination URI. + * Both the source and destination URIs must be resolvable to file:// URIs. + * + * - Parameters: + * - call: The Flutter method call containing the arguments. The arguments + * are expected to be a list containing two strings: the source URI and + * the destination URI. + * - result: The Flutter result callback to be invoked with the result + * of the move operation. On success, the destination URI string is + * returned. On failure, a `FlutterError` is returned. + */ + private func moveFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let sourceUriString = args[0] as? String, + let destinationUriString = args[1] as? String, + let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode! + let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode! + else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for moveFile", details: nil)) + return + } + do { + // Ensure destination directory exists + let destinationDirectory = destinationUrl.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: destinationDirectory.path) { + try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil) + } + try FileManager.default.moveItem(at: sourceUrl, to: destinationUrl) + result(destinationUrl.absoluteString) + } catch { + result(FlutterError(code: "MOVE_FAILED", message: "Failed to move file: \(error.localizedDescription)", details: nil)) + } + } + + /** + * Deletes the file at the given URI. The URI must be resolvable to a file:// URI. + * + * - Parameters: + * - call: The Flutter method call containing the arguments. The argument + * is expected to be a string representing the file URI. + * - result: The Flutter result callback. `true` is returned on success, + * and a `FlutterError` is returned on failure. + */ + private func deleteFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let uriString = call.arguments as? String, + let fileUrl = decodeToFileUrl(uriString: uriString), + fileUrl.scheme == "file" else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid or non-file URI provided for deleteFile", details: nil)) + return + } + do { + try FileManager.default.removeItem(at: fileUrl) + result(true) + } catch { + result(FlutterError(code: "DELETE_FILE_FAILED", message: "Failed to delete file: \(error.localizedDescription)", details: nil)) + } + } + + /** + * Opens the file at the given URI using the system's default application for the file type. + * + * - Parameters: + * - call: The Flutter method call containing the arguments. The arguments are + * expected to be a list where the first element is the URI string, and the + * optional second element is the MIME type (as a String). + * - result: The Flutter result callback. `true` is returned on successful launch, + * and a `FlutterError` is returned on failure. + */ + private func openFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [Any], + let uriString = args[0] as? String, + let mimeType = args[1] as? String?, + let fileUrl = decodeToFileUrl(uriString: uriString) + else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for openFile", details: nil)) + return + } + let filePath = fileUrl.path + if !FileManager.default.fileExists(atPath: fileUrl.path) { + os_log("File does not exist at %@", log: log, type: .info, filePath) + result(FlutterError(code: "FILE_NOT_FOUND", message: "File does not exist at \(filePath)", details: nil)) + return + } + result(doOpenFile(filePath: filePath, mimeType: mimeType != nil ? mimeType : getMimeType(fromFilename: filePath))) + } + + /** + * Complete the flutter result callback and destroy the result object + */ + private func completeFlutterResult(_ result: Any?) { + self.flutterResult?(result) + self.flutterResult = nil + } + + // MARK: - UIDocumentPickerDelegate + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + + if multipleAllowed { + var resultUrls: [String] = [] + + for url in urls { + // Start accessing the security-scoped resource. + if !url.startAccessingSecurityScopedResource() { + // Handle access error (e.g., by skipping this URL and logging an error message) + os_log("Failed to access security-scoped resource for %@", log: log, type: .info, url.absoluteString) + continue + } + accessedSecurityScopedUrls.insert(url) + + let pickedUrl: URL + if persistedUriPermission { + do { + pickedUrl = try createBookmarkUriFromUrl(fileURL: url) + } catch { + completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil)) + return + } + } else { + pickedUrl = url + } + + resultUrls.append(pickedUrl.absoluteString) + } + + completeFlutterResult(resultUrls) + } else { + // Single selection + guard let url = urls.first else { + completeFlutterResult(nil) // User cancelled + return + } + + // Start accessing the security-scoped resource. + if !url.startAccessingSecurityScopedResource() { + completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(url)", details: nil)) + return + } + accessedSecurityScopedUrls.insert(url) + + let pickedUrl: URL + if persistedUriPermission { + do { + pickedUrl = try createBookmarkUriFromUrl(fileURL: url) + } catch { + completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil)) + return + } + } else { + pickedUrl = url + } + completeFlutterResult(pickedUrl.absoluteString) + } + } + + + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + completeFlutterResult(nil) + } + + + // MARK: PHickerViewControllerelegate + + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !results.isEmpty else { + completeFlutterResult(nil) + return + } + let group = DispatchGroup() + for result in results { + group.enter() + let itemProvider = result.itemProvider + + // Determine the preferred order based on what is allowed + var typeIdentifiers: [String] = [] + if allowVideos { + typeIdentifiers.append(UTType.movie.identifier) + } + typeIdentifiers.append(UTType.image.identifier) + + var handled = false + for typeIdentifier in typeIdentifiers { + if itemProvider.hasRepresentationConforming(toTypeIdentifier: typeIdentifier) { + handleLoadedFile(for: itemProvider, typeIdentifier: typeIdentifier, group: group) + handled = true + break // Break after handling the first matching type + } + } + + // Leave the group if no suitable representation was found + if !handled { + group.leave() + } + } + // after all files have been copied, post the result + group.notify(queue: .main) { + picker.dismiss(animated: true) + if self.localFileUrls.isEmpty { + self.completeFlutterResult(nil) + } else { + self.completeFlutterResult(self.localFileUrls) + } + } + } + + /// Handles the loading of a file representation from an `NSItemProvider`. + /// + /// This function attempts to load a file of the specified `typeIdentifier` from the given `itemProvider`. + /// If successful, it copies the file to the app's local storage and appends the local file URL to the `localFileUrls` array. + /// This function is designed to be used within the `PHPickerViewControllerDelegate`'s `didFinishPicking` method to process selected assets. + /// + /// - Parameters: + /// - itemProvider: The `NSItemProvider` representing the asset to load. + /// - typeIdentifier: The UTI (Uniform Type Identifier) representing the desired file type (e.g., `public.image`, `public.movie`). + /// - group: The `DispatchGroup` to which the asynchronous file loading operation belongs. `group.leave()` is called when the operation is completed (either successfully or with an error). + /// + /// - Important: This function uses an escaping closure to handle the asynchronous loading of the file. + /// The `localFileUrls` array is a class property and is modified within the escaping closure. + private func handleLoadedFile(for itemProvider: NSItemProvider, typeIdentifier: String, group: DispatchGroup) { + itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in + guard let self = self, let url = url, error == nil else { + group.leave() + return + } + let localUrl = self.copyFileToLocalStorage(url: url) + if let localUrl = localUrl { + self.localFileUrls.append(localUrl.absoluteString) + } + group.leave() + } + } + + /// Copies a file from the provided URL into the app's temporary storage directory, returning the new URL relative to the storage root. This URL will have the "support" scheme and + /// itspath is the last pathsegment of the file URL. It will be converted to a proper file:// URL using `decodeToFileUrl` + /// + /// This function creates a unique filename for the copied file to avoid collisions. The file is stored in a location that is: + /// 1. **Private to the application:** Other apps cannot access this directory. + /// 2. **Temporary:** The system may delete files in this directory when the app is not running to free up space. Your app should be prepared to recreate these files as needed. + /// 3. **Not backed up:** Files in this directory are not backed up to iCloud or other backup services. + /// 4. **Persistent across app launches:** The relative file paths are consistent between app launches. + /// + /// - Parameter url: The source URL of the file to copy. This should be a file URL (e.g., obtained from a `PHPickerResult`). + /// - Returns: The relative path (as a String) to the copied file within the temporary storage directory if the copy is successful, otherwise `nil`. + /// You can reconstruct the full URL using `constructPersistentFileURL(for:)`. + private func copyFileToLocalStorage(url: URL) -> URL? { + let fileManager = FileManager.default + + // Use the Application Support directory, which is private to the app but persistent. + // Create a custom subdirectory for temporary files. + guard let storageDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else { + completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Could not find Application Support directory", details: nil)) + return nil + } + + // Ensure the storage directory exists + do { + try fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error creating storage directory: \(error)", details: nil)) + return nil + } + + // Create a unique filename + let uniqueFilename = "\(UUID().uuidString).\(url.pathExtension)" + let destinationURL = storageDirectory.appendingPathComponent(uniqueFilename) + + do { + // If a file with the same name already exists, remove it. This should not normally happen because of the UUID, but it's good practice. + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + // Copy the file from source to destination, and return the media URI to this location + try fileManager.copyItem(at: url, to: destinationURL) + return URL(string: "media://support/\(destinationURL.lastPathComponent)") + } catch { + completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error copying file: \(error)", details: nil)) + return nil + } + } + + + // MARK: - Helper Functions + + private func getInitialDirectoryUrl(location: SharedStorage) -> URL? { + switch location { + case SharedStorage.downloads: + return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + case SharedStorage.images: + return FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first + case SharedStorage.video: + return FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first + case SharedStorage.audio: + return FileManager.default.urls(for: .musicDirectory, in: .userDomainMask).first + case SharedStorage.files: + return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + default: + return nil + } + } + + // Stop accessing security-scoped resources when appropriate. + deinit { + // Iterate through all stored URLs and stop accessing the security-scoped resources. + for url in accessedSecurityScopedUrls { + url.stopAccessingSecurityScopedResource() + } + } +} + + +/// Packs `filename` and `uri` into a single String +/// +/// use `unpack` to retrieve the filename and uri from the packed String +func pack(filename: String, uri: URL) -> String { + return ":::\(filename)::::::\(uri.absoluteString):::" +} + +/// Unpacks `packedString` into a (filename, uri) tuple. If this is not a packed +/// string, returns the original `packedString` as (filename, nil) or, +/// if it is a Uri as (nil, the uri) +func unpack(packedString: String) -> (filename: String?, uri: URL?) { + let regex = try! NSRegularExpression(pattern: ":::([\\s\\S]*?)::::::([\\s\\S]*?):::") + let range = NSRange(packedString.startIndex.. URL? { + let unpacked = unpack(packedString: maybePacked) + return unpacked.uri +} + +/// Returns true if `maybePacked` is a valid Uri or packed Uri string. +/// +/// `maybePacked` should be a full Uri string, or a packed String containing +/// a Uri (see `pack`) +func containsUri(maybePacked: String) -> Bool { + return uriFromStringValue(maybePacked: maybePacked) != nil +} + +/// Creates a urlboomark://authority/ URL containing encoded data representing the bookmark for this URL, which +/// can then be used later +private func createBookmarkUriFromUrl(fileURL: URL) throws -> URL { + let bookmarkData = try fileURL.bookmarkData(options: .suitableForBookmarkFile, includingResourceValuesForKeys: nil, relativeTo: nil) + let bookmarkDataBase64 = bookmarkData.base64EncodedString() + let bookmarkUriString = "urlbookmark://authority/\(bookmarkDataBase64)" + guard let bookmarkUri = URL(string: bookmarkUriString) else { + throw NSError(domain: "UriUtilsMethodCallHelper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark URI from '\(bookmarkUriString)'"]) + } + return bookmarkUri +} + +/** + * Decodes a URI parameter on iOS. + * + * If the URI string starts with `urlbookmark://authority/`, it decodes the base64 encoded bookmark data to a file URL + * If the URI string starts with `media://support/` it will convert it to a file URL relateive to the applicationSupport directory + * Otherwise, it returns the original URI as a URL. + * + * - Parameter uriString: The URI string to decode. + * - Returns: The decoded `URL` or `nil` if the URI string is invalid or the bookmark data cannot be resolved. + */ +private func decodeToFileUrl(uriString: String) -> URL? { + guard let uri = URL(string: uriString) else { return nil } + if uri.scheme == "urlbookmark" { + // Decode base64 bookmark data + let base64String = String(uri.path.dropFirst()) + guard let bookmarkData = Data(base64Encoded: base64String) else { + return nil + } + do { + var isStale = false + let resolvedUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale) + + if isStale { + os_log("Warning: Bookmark data is stale for %@", log: log, type: .info, resolvedUrl.absoluteString) + return nil + } + + // Access to resolved URLs that are not persisted, is started when they are actually used + // (i.e. when creating a subdirectory in it, or picking it) + // And stopped in the deinit + return resolvedUrl + } catch { + os_log("Error resolving bookmark data: %@", log: log, type: .info, error.localizedDescription) + return nil + } + } else if uri.scheme == "media" { + guard let storageDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else { + os_log("Could not determine application support directory - required to decode support:// scheme URL", log: log, type: .error) + return nil + } + return storageDirectory.appendingPathComponent(uri.lastPathComponent, isDirectory: false) + } else { + // Regular file URL + return uri + } +} + +/** + Returns a file URI, or nil if not possible. + + Decodes a bookmark URI or a media scheme uri + */ +func decodeToFileUrl(uri: URL) -> URL? { + return uri.scheme == "file" ? uri : decodeToFileUrl(uriString: uri.absoluteString) +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/UrlSessionDelegate.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/UrlSessionDelegate.swift new file mode 100644 index 0000000..bc87715 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/UrlSessionDelegate.swift @@ -0,0 +1,628 @@ +// +// UrlSessionDelegate.swift +// background_downloader +// +// Created by Bram on 11/12/23. +// + +import Foundation +import os.log + + +public class UrlSessionDelegate : NSObject, URLSessionDelegate, URLSessionDownloadDelegate, URLSessionDataDelegate { + + static let instance = UrlSessionDelegate() + static var urlSession: URLSession? + public static var sessionIdentifier = "com.bbflight.background_downloader.Downloader" + public static var backgroundCompletionHandler: (() -> Void)? + + //MARK: URLSessionTaskDelegate + + /// Called before the task starts, and may continue, cancel or modify the original request, based on native callbacks in the [TaskOptions] of the task + public func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest) async -> (URLSession.DelayedRequestDisposition, URLRequest?) { + guard let bgdTask = getTaskFrom(urlSessionTask: task) + else { + return (.continueLoading, nil) + } + // check & process beforeTaskStartCallback and cancel the task if returned value is not nil + if bgdTask.options?.hasBeforeStartCallback() == true { + if let statusUpdate = await invokeBeforeTaskStartCallback(task: bgdTask) { + os_log("TaskId %@ interrupted by beforeTaskStart callback", log: log, type: .info, bgdTask.taskId) + BDPlugin.propertyLock.withLock( { + BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.insert(bgdTask.taskId) + }) + processStatusUpdate(task: bgdTask, + status: statusUpdate.taskStatus, + taskException: statusUpdate.exception, + responseBody: statusUpdate.responseBody, + responseHeaders: statusUpdate.responseHeaders, + responseStatusCode: statusUpdate.responseStatusCode) + return (.cancel, nil) + } + } + // check & process onStartCalback and onAuthCallback + guard bgdTask.options?.hasOnStartCallback() == true || bgdTask.options?.auth?.hasOnAuthCallback() == true + else { + return (.continueLoading, nil) + } + let (newTask, taskWasModified) = await getModifiedTask(task: bgdTask) // processes both onStart and onAuth + if !taskWasModified { + return (.continueLoading, nil) + } + // modify the request, copying the unmodified data from the original request + guard let url = validateUrl(newTask) + else { + os_log("Invalid url in modified task", log: log, type: .info) + return (.continueLoading, nil) + } + var newRequest = URLRequest(url: url) + // all original request headers are copied, then the newTask headers are set: + // you can change or add a header, but not remove one + if request.allHTTPHeaderFields != nil { + for (key, value) in request.allHTTPHeaderFields! { + newRequest.setValue(value, forHTTPHeaderField: key) + }} + for (key, value) in newTask.headers { + newRequest.setValue(value, forHTTPHeaderField: key) + } + newRequest.httpMethod = request.httpMethod + newRequest.httpBody = request.httpBody + newRequest.allowsCellularAccess = request.allowsCellularAccess + return (URLSession.DelayedRequestDisposition.useNewRequest, newRequest) + } + + /// Handle task completion + /// + /// DownloadTasks handle task completion in the :didFinishDownloadingTo function, so for download tasks + /// we only process status updates if there was an error. + /// For other tasks we handle error, or send .complete + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let bgdTask = getTaskFrom(urlSessionTask: task) else { // refers to "our" task, not a URLSessionTask + os_log("Could not find task related to urlSessionTask %d", log: log, type: .error, task.taskIdentifier) + return + } + if BDPlugin.propertyLock.withLock( { + BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.remove(bgdTask.taskId) != nil + }) { + // task was canceled before start and the + // status update already generated, so simply return without any further processing + return + } + let multipartUploader = BDPlugin.propertyLock.withLock { + BDPlugin.uploaderForUrlSessionTaskIdentifier.removeValue(forKey: task.taskIdentifier) + } + if let uploader = multipartUploader { + try? FileManager.default.removeItem(at: uploader.outputFileUrl()) + } + let responseStatusCode = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + let responseStatusDescription = HTTPURLResponse.localizedString(forStatusCode: responseStatusCode) + let responseHeaders = (task.response as? HTTPURLResponse)?.allHeaderFields + let notificationConfig = getNotificationConfigFrom(urlSessionTask: task) + if let tempUploadUrl = BDPlugin.propertyLock.withLock({ BDPlugin.tasksWithTempUploadFile.removeValue(forKey: bgdTask.taskId) }) { + try? FileManager.default.removeItem(at: tempUploadUrl) + } + if BDPlugin.holdingQueue != nil { + _Concurrency.Task { + await BDPlugin.holdingQueue?.taskFinished(bgdTask) + } + } + let responseBody = getResponseBody(taskId: bgdTask.taskId) + let taskWasProgramaticallyCanceledAfterStart = BDPlugin.propertyLock.withLock( { + BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(bgdTask.taskId) != nil + }) + guard error == nil else { + var notificationType = taskWasProgramaticallyCanceledAfterStart ? nil : NotificationType.error + // handle the error if this task wasn't programatically cancelled (in which + // case the error has been handled already) + if !taskWasProgramaticallyCanceledAfterStart { + // check if we have resume data, and if so, process it + let userInfo = (error! as NSError).userInfo + let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data + let canResume = resumeData != nil && processResumeData(task: bgdTask, resumeData: resumeData!) + // check different error codes + if (error! as NSError).code == NSURLErrorTimedOut { + os_log("Task with id %@ timed out", log: log, type: .info, bgdTask.taskId) + processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .connection, httpResponseCode: -1, description: error!.localizedDescription)) + // replace the 'running' notification, unless the task will be retried + notificationType = bgdTask.retriesRemaining > 0 ? nil : .error + } + else if (error! as NSError).code == NSURLErrorCancelled { + // cancelled with resumedata implies 'pause' + if canResume { + os_log("Paused task with id %@", log: log, type: .info, bgdTask.taskId) + processStatusUpdate(task: bgdTask, status: .paused) + if isDownloadTask(task: bgdTask) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + updateNotification(task: bgdTask, notificationType: .paused, notificationConfig: notificationConfig) + } + } + BDPlugin.propertyLock.withLock({ + _ = BDPlugin.progressInfo.removeValue(forKey: bgdTask.taskId) // ensure .running update on resume + BDPlugin.initialResponseDataProcessed.remove(bgdTask.taskId) + }) + return + } + // cancelled without resumedata implies 'cancel' + let isReEnqueuing = BDPlugin.propertyLock.withLock { + BDPlugin.tasksToReEnqueue.contains(bgdTask) + } + os_log("Canceled task with id %@", log: log, type: .info, bgdTask.taskId) + processStatusUpdate(task: bgdTask, status: .canceled) + notificationType = isReEnqueuing ? nil : .canceled + } + else { + os_log("Error for taskId %@: %@", log: log, type: .error, bgdTask.taskId, error!.localizedDescription) + processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .general, httpResponseCode: -1, description: error!.localizedDescription)) + } + } + if isDownloadTask(task: bgdTask) && notificationType != nil { + updateNotification(task: bgdTask, notificationType: notificationType!, notificationConfig: notificationConfig) + } + return + } + // there was no error + os_log("Finished task with id %@", log: log, type: .info, bgdTask.taskId) + // if this is an upload task, send final TaskStatus (based on HTTP status code). + // for download tasks, this is done in urlSession(downloadTask:, didFinishDownloadingTo:) + if isUploadTask(task: bgdTask) { + let taskException = TaskException(type: .httpResponse, httpResponseCode: responseStatusCode, description: responseStatusDescription) + let finalStatus = (200...206).contains(responseStatusCode) + ? TaskStatus.complete + : responseStatusCode == 404 + ? TaskStatus.notFound + : TaskStatus.failed + let notificationType = finalStatus == .complete ? NotificationType.complete : NotificationType.error + updateNotification(task: bgdTask, notificationType: notificationType, notificationConfig: notificationConfig) + processStatusUpdate(task: bgdTask, status: finalStatus, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode) + } + } + + //MARK: URLSessionDownloadTaskDelegate + + + + /// Process taskdelegate progress update for download task + /// + /// If the task requires progress updates, provides these at a reasonable interval + /// If this is the first update for this file, also emits a 'running' status update + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + // task is var because the filename can be changed on the first 'didWriteData' call + guard var task = getTaskFrom(urlSessionTask: downloadTask) else { return } + let progressInfo = BDPlugin.propertyLock.withLock({ + BDPlugin.progressInfo[task.taskId] + }) + if progressInfo == nil { + // first 'didWriteData' call, so send 'running' status update and show notification + os_log("Starting/resuming taskId %@", log: log, type: .info, task.taskId) + processStatusUpdate(task: task, status: TaskStatus.running) + processProgressUpdate(task: task, progress: 0.0) + BDPlugin.propertyLock.withLock({ + BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0) + }) + let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask) + if (notificationConfig != nil) { + updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig) + } + } + if !BDPlugin.propertyLock.withLock({ BDPlugin.initialResponseDataProcessed.contains(task.taskId) }) { + // response can be nil in cases when progressInfo is nil due to app resuming in foreground + if let response = downloadTask.response as? HTTPURLResponse { + BDPlugin.propertyLock.withLock( { + BDPlugin.initialResponseDataProcessed.insert(task.taskId) + }) + // get suggested filename if needed + let (filename, uri) = unpack(packedString: task.filename) + if filename == "?" { + if (uri == nil) { + let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: task, responseHeaders: response.allHeaderFields) + if newTask.filename != task.filename { + storeModifiedTask(task: newTask) + task = newTask + } + } else { + let suggestedFilename = suggestFilename(responseHeaders: response.allHeaderFields, urlString: task.url) + let packed = pack(filename: suggestedFilename.isEmpty ? "unknown" : suggestedFilename, uri: uri!) + let newTask = task.copyWith(filename: packed) + storeModifiedTask(task: newTask) + task = newTask + } + } + extractContentType(responseHeaders: response.allHeaderFields, task: task) + // obtain content length override, if needed and available + if totalBytesExpectedToWrite == -1 { + let contentLength = getContentLength(responseHeaders: response.allHeaderFields, task: task) + if contentLength != -1 { + BDPlugin.propertyLock.withLock({ + BDPlugin.tasksWithContentLengthOverride[task.taskId] = contentLength + }) + } + } + // Check if there is enough space + if insufficientSpace(contentLength: totalBytesExpectedToWrite) { + let notProgrammaticallyCancelled = BDPlugin.propertyLock.withLock({ + !BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.contains(task.taskId) + }) + if notProgrammaticallyCancelled { + os_log("Error for taskId %@: Insufficient space to store the file to be downloaded", log: log, type: .error, task.taskId) + processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .fileSystem, httpResponseCode: -1, description: "Insufficient space to store the file to be downloaded for taskId \(task.taskId)")) + BDPlugin.propertyLock.withLock({ + _ = BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.insert(task.taskId) + }) + downloadTask.cancel() + } + return + } + // check if the task is resumable + if task.allowPause { + // header lookup must be case-insensitive, and a partial + // response also implies the server accepts ranges + let httpResponse = downloadTask.response as? HTTPURLResponse + let acceptRangesHeader = httpResponse?.value(forHTTPHeaderField: "Accept-Ranges") + let taskCanResume = acceptRangesHeader == "bytes" || httpResponse?.statusCode == 206 + processCanResume(task: task, taskCanResume: taskCanResume) + if taskCanResume { + BDPlugin.propertyLock.withLock({ + _ = BDPlugin.taskIdsThatCanResume.insert(task.taskId) + }) + } + } + } + } + let contentLength = BDPlugin.propertyLock.withLock({ + let l = totalBytesExpectedToWrite != -1 ? totalBytesExpectedToWrite : BDPlugin.tasksWithContentLengthOverride[task.taskId] ?? -1 + BDPlugin.remainingBytesToDownload[task.taskId] = l - totalBytesWritten + return l + }) + updateProgress(task: task, totalBytesExpected: contentLength, totalBytesDone: totalBytesWritten) + } + + /// Process taskdelegate progress update for upload task + /// + /// If the task requires progress updates, provide these at a reasonable interval + /// If this is the first update for this file, also emit a 'running' status update + public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + let urlSessionTask = task + guard let task = getTaskFrom(urlSessionTask: task) else {return} + let taskId = task.taskId + let progressInfo = BDPlugin.propertyLock.withLock({ + return BDPlugin.progressInfo[taskId] + }) + if progressInfo == nil { + // first call to this method: send 'running' status update + processStatusUpdate(task: task, status: TaskStatus.running) + processProgressUpdate(task: task, progress: 0.0) + BDPlugin.propertyLock.withLock({ + BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0) + }) + // notify if needed + let notificationConfig = getNotificationConfigFrom(urlSessionTask: urlSessionTask) + if (notificationConfig != nil) { + updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig) + } + } + updateProgress(task: task, totalBytesExpected: totalBytesExpectedToSend, totalBytesDone: totalBytesSent) + } + + /// Process end of downloadTask sent by the urlSession. + /// + /// If successful, (over)write file to final destination per Task info + public func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + guard var task = getTaskFrom(urlSessionTask: downloadTask), + let response = downloadTask.response as? HTTPURLResponse + else { + os_log("Could not find task associated urlSessionTask %d, or did not get HttpResponse", log: log, type: .info, downloadTask.taskIdentifier) + return} + let responseHeaders = response.allHeaderFields + let taskId = task.taskId + let (mimeType, charSet) = BDPlugin.propertyLock.withLock({ + let mimeType = BDPlugin.mimeTypes.removeValue(forKey: taskId) + let charSet = BDPlugin.charSets.removeValue(forKey: taskId) + BDPlugin.tasksWithModifications.removeValue(forKey: taskId) + BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: taskId) + BDPlugin.initialResponseDataProcessed.remove(taskId) + return (mimeType, charSet) + }) + let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask) + if response.statusCode == 404 { + let responseBody = readFile(url: location) + processStatusUpdate(task: task, status: TaskStatus.notFound, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: response.statusCode) + updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig) + return + } + if !(200...206).contains(response.statusCode) { + os_log("TaskId %@ returned response code %d", log: log, type: .info, task.taskId, response.statusCode) + let responseBody = readFile(url: location) + processStatusUpdate(task: task, status: TaskStatus.failed, taskException: TaskException(type: .httpResponse, httpResponseCode: response.statusCode, description: responseBody?.isEmpty == false ? responseBody! : HTTPURLResponse.localizedString(forStatusCode: response.statusCode))) + if task.retriesRemaining == 0 { + // update notification only if no retries remaining + updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig) + } + return + } + do { + var finalStatus = TaskStatus.failed + var taskException: TaskException? = nil + var responseBody: String? = nil + defer { + processStatusUpdate(task: task, + status: finalStatus, + taskException: taskException, + responseBody: responseBody, + responseHeaders: responseHeaders, + responseStatusCode: response.statusCode, + mimeType: mimeType, + charSet: charSet) + if finalStatus != TaskStatus.failed || task.retriesRemaining == 0 { + // update notification only if not failed, or no retries remaining + updateNotification(task: task, notificationType: notificationTypeForTaskStatus(status: finalStatus), notificationConfig: notificationConfig) + } + } + if (isDownloadTask(task: task)) { + // Determine directoryUri and fileUrl based on URI mode or filepath mode + var directoryUrl: URL? + var fileUrl: URL? + + // filename field can contain filename and/or a file url (if already started) + let unpackedFilename = unpack(packedString: task.filename) + let filename = unpackedFilename.filename ?? "unknown" + fileUrl = unpackedFilename.uri + // directory may contain a path or a Uri representing the full destination directory + if let directoryUri = uriFromStringValue(maybePacked: task.directory) { + // URI mode + let uri = decodeToFileUrl(uri: directoryUri) + guard let uri = uri else { + os_log("Invalid directory URI (could not convert bookmark): %@", log: log, type: .error, directoryUri.absoluteString) + taskException = TaskException(type: .fileSystem, httpResponseCode: -1, + description: "Invalid directory URI (could not convert bookmark): %@ \(directoryUri.absoluteString)") + return + } + guard uri.isFileURL else { + os_log("Invalid directory URI (not a file URL): %@", log: log, type: .error, uri.absoluteString) + taskException = TaskException(type: .fileSystem, httpResponseCode: -1, + description: "Invalid directory URI (not a file URL): \(uri.absoluteString)") + return + } + directoryUrl = uri + fileUrl = uri.appendingPathComponent(filename) + // store the full Uri in the task.filename so it can be retrieved + let newTask = task.copyWith(filename: pack(filename: filename, uri: fileUrl!)) + task = newTask + } else { + // Filepath mode + directoryUrl = try directoryForTask(task: task) + fileUrl = directoryUrl?.appendingPathComponent(filename) + } + + // Guard against directoryUri or fileUrl being nil + guard let directoryUri = directoryUrl, var fileUrl = fileUrl else { + os_log("Could not determine directory Uri or file Uri", log: log, type: .error) + taskException = TaskException(type: .fileSystem, httpResponseCode: -1, + description: "Could not determine directory or file Uri") + return + } + + do { + if !FileManager.default.fileExists(atPath: directoryUri.path) { + try FileManager.default.createDirectory(at: directoryUri, withIntermediateDirectories: true) + } + + if FileManager.default.fileExists(atPath: fileUrl.path) { + try FileManager.default.removeItem(at: fileUrl) + } + + try FileManager.default.moveItem(at: location, to: fileUrl) + + do { + if UserDefaults.standard.bool(forKey: BDPlugin.keyConfigExcludeFromCloudBackup) + { + try fileUrl.setCloudBackup(exclude: true) + os_log("Excluded from iCloud backup: %@", log: log, type: .info, fileUrl.path) + } + } catch { + os_log("Could not exclude from iCloud backup: %@ - %@", log: log, type: .info, fileUrl.path, error.localizedDescription) + } + finalStatus = TaskStatus.complete + + } catch { + os_log("File operation failed: %@", log: log, type: .error, error.localizedDescription) + taskException = TaskException(type: .fileSystem, httpResponseCode: -1, description: "File operation failed: \(error.localizedDescription)") + return + } + } else { + // this is a DataTask, so we read the file as the responseBody + responseBody = readFile(url: location) + try? FileManager.default.removeItem(at: location); + finalStatus = .complete + } + } catch { + os_log("Uncaught file download error for taskId %@ and file %@: %@", log: log, type: .error, task.taskId, task.filename, error.localizedDescription) + } + } + + //MARK: URLSessionDataDelegate + + /// Collects incoming data following a file upload, by appending the data block to a static dictionary keyed by taskId + public func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) + { + guard let task = getTaskFrom(urlSessionTask: dataTask) + else { + os_log("Could not find task associated urlSessionTask %d", log: log, type: .info, dataTask.taskIdentifier) + return + } + BDPlugin.propertyLock.withLock({ + var dataList = BDPlugin.responseBodyData[task.taskId] ?? [] + dataList.append(data) + BDPlugin.responseBodyData[task.taskId] = dataList + }) + } + + //MARK: URLSessionDelegate + + /// Upon completion of download of all files, call the completion handler + public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + guard + let handler = UrlSessionDelegate.backgroundCompletionHandler, + session.configuration.identifier == UrlSessionDelegate.sessionIdentifier + else { + os_log("No handler or no identifier match in urlSessionDidFinishEvents", log: log, type: .info) + return + } + handler() + } + } + + //MARK: Helpers + + /// Read the contents of a file to a String, or nil if unable + private func readFile(url: URL) -> String? { + do { + let data = try Data(contentsOf: url) + let string = String(data: data, encoding: .utf8) + return string + } catch { + return nil + } + } + + /// Get response body for upload task with this [taskId] + private func getResponseBody(taskId: String) -> String? { + guard let dataList = BDPlugin.propertyLock.withLock( { BDPlugin.responseBodyData[taskId] }) else { + return nil + } + var allData: Data? = nil + dataList.forEach { data in + if allData == nil { + allData = data + } else { + allData?.append(data) + } + } + if (allData == nil) { + return nil + } + return String(data: allData!, encoding: .utf8)! + } + + //MARK: UrlSession and related Task helpers + + /// Creates a urlSession + /// + /// Configures defaultResourceTimeout, defaultRequestTimeout and proxy based on configuration parameters, + /// or defaults + static func createUrlSession() -> Void { + if UrlSessionDelegate.urlSession != nil { + return + } + let config = URLSessionConfiguration.background(withIdentifier: UrlSessionDelegate.sessionIdentifier) + let defaults = UserDefaults.standard + let storedTimeoutIntervalForResource = defaults.double(forKey: BDPlugin.keyConfigResourceTimeout) // seconds + let timeOutIntervalForResource = storedTimeoutIntervalForResource > 0 ? storedTimeoutIntervalForResource : BDPlugin.defaultResourceTimeout + os_log("timeoutIntervalForResource = %d seconds", log: log, type: .info, Int(timeOutIntervalForResource)) + config.timeoutIntervalForResource = timeOutIntervalForResource + let storedTimeoutIntervalForRequest = defaults.double(forKey: BDPlugin.keyConfigRequestTimeout) // seconds + let timeoutIntervalForRequest = storedTimeoutIntervalForRequest > 0 ? storedTimeoutIntervalForRequest : BDPlugin.defaultRequestTimeout + os_log("timeoutIntervalForRequest = %d seconds", log: log, type: .info, Int(timeoutIntervalForRequest)) + config.timeoutIntervalForRequest = timeoutIntervalForRequest + let proxyAddress = defaults.string(forKey: BDPlugin.keyConfigProxyAdress) + let proxyPort = defaults.integer(forKey: BDPlugin.keyConfigProxyPort) + if (proxyAddress != nil && proxyPort != 0) { + config.connectionProxyDictionary = [ + kCFNetworkProxiesHTTPEnable: true, + kCFNetworkProxiesHTTPProxy: proxyAddress!, + kCFNetworkProxiesHTTPPort: proxyPort, + "HTTPSEnable": true, + "HTTPSProxy": proxyAddress!, + "HTTPSPort": proxyPort + ] + os_log("Using proxy %@:%d for all tasks", log: log, type: .info, proxyAddress!, proxyPort) + } else { + os_log("Not using proxy for any task", log: log, type: .info) + } + UrlSessionDelegate.urlSession = URLSession(configuration: config, delegate: UrlSessionDelegate.instance, delegateQueue: nil) + } + + /// Return all tasks in this urlSession + static func getAllTasks() async -> [Task] { + UrlSessionDelegate.createUrlSession() + guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] } + return urlSessionTasks.compactMap({ getTaskFrom(urlSessionTask: $0) }) + } + + /// Return the active task with this taskId, or nil + static func getTaskWithId(taskId: String) async -> Task? { + return await getAllTasks().first(where: { $0.taskId == taskId }) + } + + /// Return all urlSessionsTasks in this urlSession + static func getAllUrlSessionTasks(group: String? = nil) async -> [URLSessionTask] { + UrlSessionDelegate.createUrlSession() + if (group == nil) { + guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] } + return urlSessionTasks + } + guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] } + let urlSessionTasksInGroup = urlSessionTasks.filter({ + guard let task = getTaskFrom(urlSessionTask: $0) else { return false } + return task.group == group + }) + return urlSessionTasksInGroup + } + + /// Return the urlSessionTask matching this taskId, or nil + static func getUrlSessionTaskWithId(taskId: String) async -> URLSessionTask? { + guard let urlSessionTask = await getAllUrlSessionTasks().first(where: { + guard let task = getTaskFrom(urlSessionTask: $0) else { return false } + return task.taskId == taskId + }) else { return nil } + return urlSessionTask + } + + /// Returns a `task` that may be modified through callbacks, and a bool to indicate that the task was modified. + /// + /// Callbacks would be attached to the task via its `Task.options` property, and if + /// present will be invoked by starting a taskDispatcher on a background isolate, then + /// sending the callback request via the MethodChannel. + /// + /// First test is for auth refresh (the onAuth callback), then the onStart callback. Both + /// callbacks run in a Dart isolate, and may return a modified task, which will be used + /// for the actual task execution. + func getModifiedTask(task: Task) async -> (Task, Bool) { + var authTask: Task? + var taskWasModified: Bool = false + if let auth = task.options?.auth { + // Refresh token if needed + if auth.isTokenExpired() && auth.hasOnAuthCallback() == true { + authTask = await invokeOnAuthCallback(task: task) + } + taskWasModified = authTask != nil + authTask = authTask ?? task // Either original or newly authorized + guard let newAuth = authTask?.options?.auth else { return (authTask ?? task, taskWasModified) } + // Insert query parameters and headers + taskWasModified = true + let uri = newAuth.addOrUpdateQueryParams( + url: authTask!.url, + queryParams: newAuth.getExpandedAccessQueryParams() + ) + var headers = authTask!.headers + headers.merge(newAuth.getExpandedAccessHeaders()) { (_, new) in new } + authTask = authTask!.copyWith(url: uri.absoluteString, headers: headers) + } + authTask = authTask ?? task + guard task.options?.hasOnStartCallback() == true else { return (authTask!, taskWasModified) } + // onStart callback + let modifiedTask = await invokeOnTaskStartCallback(task: authTask!) + taskWasModified = taskWasModified || modifiedTask != nil + return (modifiedTask ?? authTask!, taskWasModified) + } +} diff --git a/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/WiFi.swift b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/WiFi.swift new file mode 100644 index 0000000..4ba6a45 --- /dev/null +++ b/third_party/background_downloader/ios/background_downloader/Sources/background_downloader/WiFi.swift @@ -0,0 +1,104 @@ +// +// WiFi.swift +// background_downloader +// +// Created by Bram on 1/19/24. +// + +import Foundation +import os.log + +/// WiFi requirement modes at the application level +enum RequireWiFi: Int { + case + asSetByTask, + forAllTasks, + forNoTasks +} + +/// Manages changes to WiFi requirement, by re-enqueuing tasks if needed +class WiFiQueue { + static let shared = WiFiQueue() + private let requireWiFiChangeQueue = DispatchQueue(label: "com.bbflight.background_downloader.requireWiFiChangeQueue", target: DispatchQueue.global()) + private let reEnqueueQueue = DispatchQueue(label: "com.bbflight.background_downloader.reEnqueueQueue", target: DispatchQueue.global()) + private let requireWiFiChangeSemaphore = DispatchSemaphore(value: 0) + + // Private initializer to prevent creating new instances + private init() {} + + // Change the application level WiFi requirement and re-enqueue tasks as necessary + func requireWiFiChange(requireWiFi: RequireWiFi, rescheduleRunningTasks: Bool, alsoRestartUploads: Bool) { + requireWiFiChangeQueue.async { + _Concurrency.Task { + BDPlugin.requireWiFi = requireWiFi + let defaults = UserDefaults.standard + defaults.setValue(requireWiFi.rawValue, forKey: BDPlugin.keyRequireWiFi) + UrlSessionDelegate.createUrlSession() + guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks + else { + self.reEnqueuesDone() + return + } + var haveReEnqueued = false + urlSessionTasks.forEach { urlSessionTask in + if ((urlSessionTask is URLSessionDownloadTask || urlSessionTask is URLSessionUploadTask) && (urlSessionTask.state == .running || urlSessionTask.state == .suspended)) { + guard let task = getTaskFrom(urlSessionTask: urlSessionTask) else { + return + } + // compare with the request the task was created with, which the URLSession + // keeps across app launches, so tasks created by an earlier launch are included + let urlSessionTaskRequiresWiFi = urlSessionTask.originalRequest?.allowsCellularAccess == false + BDPlugin.propertyLock.withLock { + if taskRequiresWiFi(task: task) != urlSessionTaskRequiresWiFi { + // requirement differs, so we need to re-enqueue + if BDPlugin.progressInfo[task.taskId] == nil { + // enqueued only, so ensure it is re-enqueued and cancel + haveReEnqueued = true + BDPlugin.tasksToReEnqueue.insert(task) + urlSessionTask.cancel() + } else { + if (rescheduleRunningTasks && isDownloadTask(task: task)) || (alsoRestartUploads && isUploadTask(task: task)) { + // cancel without resume data so the new URLRequest (with the updated + // allowsCellularAccess setting) is used; downloadTask(withResumeData:) + // would reuse the request embedded in the resume data + haveReEnqueued = true + BDPlugin.tasksToReEnqueue.insert(task) + urlSessionTask.cancel() + } + } + } + } + } + } + if !haveReEnqueued { + self.reEnqueuesDone() // nothing left to do + } + } + self.requireWiFiChangeSemaphore.wait() // wait for re-enqueues to complete + } + } + + func reEnqueuesDone() { + self.requireWiFiChangeSemaphore.signal() + } + + + /// Re-enqueue this task and associated data. Nil signals end of batch + func reEnqueue(_ enqueueItem: EnqueueItem?) { + reEnqueueQueue.async { + _Concurrency.Task { + guard let reEnqueueData = enqueueItem else { + // nil value indicates end of batch of re-enqueues + self.reEnqueuesDone() + return + } + let timeSinceCreated = Date().timeIntervalSince(reEnqueueData.created) + if timeSinceCreated < 0.3 { + try await _Concurrency.Task.sleep(nanoseconds: UInt64((0.3 - timeSinceCreated) * 1_000_000_000)) + } + await enqueueItem?.enqueue() + try await _Concurrency.Task.sleep(nanoseconds: 20_000_000) + } + } + } +} diff --git a/third_party/background_downloader/lib/background_downloader.dart b/third_party/background_downloader/lib/background_downloader.dart new file mode 100644 index 0000000..fe3acf3 --- /dev/null +++ b/third_party/background_downloader/lib/background_downloader.dart @@ -0,0 +1,25 @@ +/// A comprehensive background file downloader and uploader for iOS, Android, Desktop and Web +/// +/// Use [FileDownloader] to download and upload files. +library; + +export 'src/database.dart'; +export 'src/exceptions.dart'; +export 'src/file_downloader.dart'; +export 'src/models.dart'; +export 'src/options/auth.dart'; +export 'src/options/auth_callback.dart'; +export 'src/options/mtls.dart'; +export 'src/options/task_options.dart'; +export 'src/permissions.dart' show PermissionType, PermissionStatus; +export 'src/persistent_storage.dart'; +export 'src/progress_widget.dart'; +export 'src/queue/task_queue.dart'; +export 'src/task.dart'; +export 'src/transfer.dart'; +export 'src/transfers.dart'; +export 'src/transfer_hint.dart'; +export 'src/uri/uri_utils.dart'; +export 'src/widgets/transfer_button.dart'; +export 'src/widgets/transfer_list_tile.dart'; +export 'src/widgets/transfer_progress_bar.dart'; diff --git a/third_party/background_downloader/lib/src/base_downloader.dart b/third_party/background_downloader/lib/src/base_downloader.dart new file mode 100644 index 0000000..aa10b1f --- /dev/null +++ b/third_party/background_downloader/lib/src/base_downloader.dart @@ -0,0 +1,1330 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import 'chunk.dart'; +import 'database.dart'; +import 'exceptions.dart'; +import 'file_downloader.dart'; +import 'models.dart'; +import 'native_downloader.dart'; +import 'permissions.dart'; +import 'persistent_storage.dart'; +import 'queue/task_queue.dart'; +import 'task.dart'; +import 'web_downloader.dart' + if (dart.library.io) 'desktop/desktop_downloader.dart'; + +/// Common download functionality +/// +/// Concrete subclass will implement platform-specific functionality, eg +/// [DesktopDownloader] for dart based desktop platforms, and +/// [NativeDownloader] for iOS and Android +/// +/// The common functionality mostly relates to: +/// - callback handling (for groups of tasks registered via the [FileDownloader]) +/// - tasks waiting to retry and retry handling +/// - Task updates provided to the [FileDownloader] +/// - Pause/resume status and information +abstract base class BaseDownloader { + final log = Logger('BaseDownloader'); + + static const databaseVersion = 1; + + /// Special group name for tasks that download a chunk, as part of a + /// [ParallelDownloadTask] + static const chunkGroup = 'chunk'; + + /// Completes when initialization is complete and downloader ready for use + final _readyCompleter = Completer(); + + /// True when initialization is complete and downloader ready for use + Future get ready => _readyCompleter.future; + + /// Persistent storage + late final PersistentStorage _storage; + late final Database database; + + /// Set of tasks that are in `waitingToRetry` status + final tasksWaitingToRetry = {}; + + /// Completers used to check task completion in convenience functions + final awaitTasks = >{}; + + /// Registered short status callback for convenience down/upload tasks + /// + /// Short callbacks omit the [Task] as they are available from the closure + final _shortTaskStatusCallbacks = {}; + + /// Registered short progress callback for convenience down/upload tasks + /// + /// Short callbacks omit the [Task] as they are available from the closure + final _shortTaskProgressCallbacks = {}; + + /// Registered [TaskStatusCallback] for convenience batch down/upload tasks + final _taskStatusCallbacks = {}; + + /// Registered [TaskProgressCallback] for convenience batch down/upload tasks + final _taskProgressCallbacks = {}; + + /// Registered [TaskStatusCallback] for each group + final groupStatusCallbacks = {}; + + /// Registered [TaskProgressCallback] for each group + final groupProgressCallbacks = {}; + + /// Registered [TaskStatusCallback] for Transfers manager for each group + final groupTransferStatusCallbacks = {}; + + /// Registered [TaskProgressCallback] for Transfers manager for each group + final groupTransferProgressCallbacks = {}; + + /// Active batches + final _batches = []; + + /// Registered [TaskNotificationTapCallback] for each group + final groupNotificationTapCallbacks = {}; + + /// Registered [TaskNotificationTapCallback] for Transfers manager for each group + final groupTransferNotificationTapCallbacks = + {}; + + /// List of notification configurations + final notificationConfigs = {}; + + /// StreamController for [TaskUpdate] updates + var updates = StreamController(); + + /// Groups tracked in persistent database + final trackedGroups = {}; + + /// Map of tasks and completer to indicate whether task can be resumed + final canResumeTask = >{}; + + /// Flag indicating we have retrieved missed data + @visibleForTesting + var retrievedLocallyStoredData = false; + + /// Connected TaskQueues that will receive a signal upon task completion + final taskQueues = []; + + /// Permissions service object + final permissionsService = PermissionsService.instance(); + + /// Stream to serialize database updates + final _databaseUpdates = + StreamController< + (Task, TaskStatus?, double?, int, TaskException?, Completer) + >(); + Future _lastDatabaseUpdate = Future.value(); + + /// Wait for every write queued since the previous flush, surfacing any + /// failure in that batch even when a subsequent write succeeds. + Future flushDatabaseUpdates() async { + final pending = _lastDatabaseUpdate; + try { + await pending; + } finally { + if (identical(_lastDatabaseUpdate, pending)) { + _lastDatabaseUpdate = Future.value(); + } + } + } + + BaseDownloader(); + + factory BaseDownloader.instance( + PersistentStorage persistentStorage, + Database database, + ) { + final instance = switch (defaultTargetPlatform) { + .android => AndroidDownloader(), + .iOS => IOSDownloader(), + .linux || .macOS || .windows => DesktopDownloader(), + _ => throw ArgumentError( + 'Platform $defaultTargetPlatform is not supported', + ), + }; + instance._storage = persistentStorage; + instance.database = database; + // The factory cannot await initialization. Own that future here and expose + // both success and the original error through ready, after subclass setup + // (not before the native background handler has been installed). + instance.ready.ignore(); + unawaited( + instance.initialize().then( + (_) => instance._readyCompleter.complete(true), + onError: (Object error, StackTrace stack) => + instance._readyCompleter.completeError(error, stack), + ), + ); + return instance; + } + + final Connectivity _connectivity = Connectivity(); + StreamSubscription>? _connectivitySubscription; + + /// Whether the device currently has an active network connection + bool isConnected = true; + + /// Whether the active network connection is Wi-Fi or Ethernet + bool isWiFi = false; + + /// Initialize + /// + /// Initializes the PersistentStorage instance and if necessary perform database + /// migration, starts listening for database update commands and + /// then initializes the subclassed implementation for desktop or native + @mustCallSuper + Future initialize() async { + await _storage.initialize(); + _databaseUpdates.stream + .asyncMap((data) async { + try { + await _consumeUpdateTaskInDatabase( + data.$1, + data.$2, + data.$3, + data.$4, + data.$5, + ); + data.$6.complete(); + } catch (error, stack) { + // A failed durable write belongs to the caller's flush barrier, + // not the stream's unhandled-error zone. Keep the queue alive. + data.$6.completeError(error, stack); + } + }) + .listen((_) {}); + await _initConnectivity(); + } + + Future _initConnectivity() async { + // Initial connectivity is part of readiness, not a detached .then future. + // A platform error must reach the initialization owner with its stack. + final results = await _connectivity.checkConnectivity(); + _onConnectivityChanged( + results.any((r) => r != ConnectivityResult.none), + results.any( + (r) => r == ConnectivityResult.wifi || r == ConnectivityResult.ethernet, + ), + ); + _connectivitySubscription = _connectivity.onConnectivityChanged.listen(( + results, + ) { + final connected = results.any((r) => r != ConnectivityResult.none); + final wifi = results.any( + (r) => r == ConnectivityResult.wifi || r == ConnectivityResult.ethernet, + ); + _onConnectivityChanged(connected, wifi); + }); + } + + void _onConnectivityChanged(bool connected, bool wifi) { + final wasConnected = isConnected; + final wasWiFi = isWiFi; + isConnected = connected; + isWiFi = wifi; + + if (connected && (!wasConnected || (!wasWiFi && wifi))) { + final tasksToRetry = List.from(tasksWaitingToRetry); + for (final task in tasksToRetry) { + if (!task.requiresWiFi || isWiFi) { + tasksWaitingToRetry.remove(task); + log.info('Network available, resuming held task ${task.taskId}'); + resume(task).then((resumed) { + if (!resumed) enqueue(task); + }); + } + } + } + } + + /// Configures the downloader + /// + /// Configuration is either a single configItem or a list of configItems. + /// Each configItem is a (String, dynamic) where the String is the config + /// type and 'dynamic' can be any appropriate parameter, including another Record. + /// [globalConfig] is routed to every platform, whereas the platform specific + /// ones only get routed to that platform, after the global configs have + /// completed. + /// If a config type appears more than once, they will all be executed in order, + /// with [globalConfig] executed before the platform-specific config. + /// + /// Returns a list of (String, String) which is the config type and a response + /// which is empty if OK, 'not implemented' if the item could not be recognized and + /// processed, or may contain other error/warning information + Future> configure({ + dynamic globalConfig, + dynamic androidConfig, + dynamic iOSConfig, + dynamic desktopConfig, + }) async { + final global = globalConfig is List ? globalConfig : [globalConfig]; + final rawPlatformConfig = platformConfig( + androidConfig: androidConfig, + iOSConfig: iOSConfig, + desktopConfig: desktopConfig, + ); + final platform = rawPlatformConfig is List + ? rawPlatformConfig + : [rawPlatformConfig]; + return await Future.wait( + [ + ...global, + ...platform, + ].where((e) => e != null).map((e) => configureItem(e)), + ); + } + + /// Returns the config for the platform, e.g. the [androidConfig] parameter + /// on Android + dynamic platformConfig({ + dynamic globalConfig, + dynamic androidConfig, + dynamic iOSConfig, + dynamic desktopConfig, + }); + + /// Configures one [configItem] and returns the (String, String) result + /// + /// If the second element is 'not implemented' then the method did not act on + /// the [configItem] + Future<(String, String)> configureItem((String, dynamic) configItem); + + /// Retrieve data that was stored locally because it could not be + /// delivered to the downloader + Future retrieveLocallyStoredData() async { + if (!retrievedLocallyStoredData) { + final resumeDataMap = await popUndeliveredData(Undelivered.resumeData); + for (final jsonString in resumeDataMap.values) { + final resumeData = ResumeData.fromJsonString(jsonString); + await setResumeData(resumeData); + await setPausedTask(resumeData.task); + } + final statusUpdateMap = await popUndeliveredData( + Undelivered.statusUpdates, + ); + for (final jsonString in statusUpdateMap.values) { + processStatusUpdate(TaskStatusUpdate.fromJsonString(jsonString)); + } + final progressUpdateMap = await popUndeliveredData( + Undelivered.progressUpdates, + ); + for (final jsonString in progressUpdateMap.values) { + processProgressUpdate(TaskProgressUpdate.fromJsonString(jsonString)); + } + await flushDatabaseUpdates(); + retrievedLocallyStoredData = true; + } + } + + /// Returns the [TaskNotificationConfig] for this [task] or null + /// + /// Matches on task, then on group, then on default + TaskNotificationConfig? notificationConfigForTask(Task task) => + notificationConfigForTaskUsingConfigSet(task, notificationConfigs); + + /// Returns the [TaskNotificationConfig] for this [task] or null + /// + /// Matches on task, then on group, then on default + /// This method is used in isolate context, where the [notificationConfigs] + /// are not directly accessible + static TaskNotificationConfig? notificationConfigForTaskUsingConfigSet( + Task task, + Set taskNotificationConfigs, + ) { + if (task.group == chunkGroup || task is DataTask) { + return null; + } + return taskNotificationConfigs.firstWhereOrNull( + (config) => config.taskOrGroup == task, + ) ?? + taskNotificationConfigs.firstWhereOrNull( + (config) => config.taskOrGroup == task.group, + ) ?? + taskNotificationConfigs.firstWhereOrNull( + (config) => config.taskOrGroup == null, + ); + } + + /// Registers the [Task.notificationConfig] of this [task], if set, as the + /// notification configuration for this task, overriding any configuration + /// for its group or the default. + /// + /// [Task.notificationConfig] is not serialized, so registering it (keyed on + /// the task) ensures it is also used when the task is retried or resumed + void registerTaskNotificationConfig(Task task) { + final config = task.notificationConfig; + if (config == null) { + return; + } + final taskConfig = TaskNotificationConfig( + taskOrGroup: task, + running: config.running, + complete: config.complete, + error: config.error, + paused: config.paused, + canceled: config.canceled, + progressBar: config.progressBar, + tapOpensFile: config.tapOpensFile, + groupNotificationId: config.groupNotificationId, + ); + notificationConfigs.remove(taskConfig); // equality is based on taskOrGroup + notificationConfigs.add(taskConfig); + } + + /// Enqueue the task + @mustCallSuper + Future enqueue(Task task) async { + registerTaskNotificationConfig(task); + if (task.allowPause) { + canResumeTask[task] = Completer(); + } + return true; // dummy return, actual enqueue happens in subclass + } + + /// Enqueue a list of tasks + Future> enqueueAll(Iterable tasks); + + /// Enqueue the [task] and wait for completion + /// + /// Returns the final [TaskStatus] of the [task]. + /// This method is used to enqueue: + /// 1. `download` and `upload` tasks, which may have a short callback + /// for status and progress (omitting Task) + /// 2. `downloadBatch` and `uploadBatch`, which may have a full callback + /// that is used for every task in the batch + Future enqueueAndAwait( + Task task, { + void Function(TaskStatus)? onStatus, + void Function(double)? onProgress, + TaskStatusCallback? taskStatusCallback, + TaskProgressCallback? taskProgressCallback, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) async { + // store the task-specific callbacks + if (onStatus != null) { + _shortTaskStatusCallbacks[task.taskId] = onStatus; + } + if (onProgress != null) { + _shortTaskProgressCallbacks[task.taskId] = onProgress; + } + if (taskStatusCallback != null) { + _taskStatusCallbacks[task.taskId] = taskStatusCallback; + } + if (taskProgressCallback != null) { + _taskProgressCallbacks[task.taskId] = taskProgressCallback; + } + // make sure the `updates` field is set correctly + final requiredUpdates = onProgress != null || taskProgressCallback != null + ? Updates.statusAndProgress + : Updates.status; + final Task taskToEnqueue; + if (task.updates != requiredUpdates) { + log.warning( + 'TaskId ${task.taskId} has `updates` set to ${task.updates} but this should be ' + '$requiredUpdates. Change to avoid issues.', + ); + taskToEnqueue = task.copyWith(updates: requiredUpdates); + } else { + taskToEnqueue = task; + } + // start the elapsedTime timer if necessary. It is cancelled when the + // taskCompleter completes (when the task itself completes) + Timer? timer; + if (onElapsedTime != null) { + final interval = elapsedTimeInterval ?? const Duration(seconds: 5); + timer = Timer.periodic(interval, (timer) { + onElapsedTime(interval * timer.tick); + }); + } + // Create taskCompleter and enqueue the task. + // The completer will be completed in the internal status callback + final taskCompleter = Completer(); + awaitTasks[taskToEnqueue] = taskCompleter; + final enqueueSuccess = await enqueue(taskToEnqueue); + if (!enqueueSuccess) { + log.warning('Could not enqueue task $taskToEnqueue'); + timer?.cancel(); + awaitTasks.remove(taskToEnqueue); + _shortTaskStatusCallbacks.remove(task.taskId); + _shortTaskProgressCallbacks.remove(task.taskId); + _taskStatusCallbacks.remove(task.taskId); + _taskProgressCallbacks.remove(task.taskId); + return Future.value( + TaskStatusUpdate( + taskToEnqueue, + TaskStatus.failed, + TaskException('Could not enqueue task $taskToEnqueue'), + ), + ); + } + if (timer != null) { + taskCompleter.future.then((_) => timer?.cancel()); + } + return taskCompleter.future; + } + + /// Enqueue a list of tasks and wait for completion + /// + /// Returns a [Batch] object + Future enqueueAndAwaitBatch( + List tasks, { + BatchProgressCallback? batchProgressCallback, + TaskStatusCallback? taskStatusCallback, + TaskProgressCallback? taskProgressCallback, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) async { + assert(tasks.isNotEmpty, 'List of tasks cannot be empty'); + if (batchProgressCallback != null) { + batchProgressCallback(0, 0); // initial callback + } + Timer? timer; + if (onElapsedTime != null) { + final interval = elapsedTimeInterval ?? const Duration(seconds: 5); + timer = Timer.periodic(interval, (timer) { + onElapsedTime(interval * timer.tick); + }); + } + final batch = Batch(tasks, batchProgressCallback); + _batches.add(batch); + final taskFutures = >[]; + var counter = 0; + for (final task in tasks) { + taskFutures.add( + enqueueAndAwait( + task, + taskStatusCallback: taskStatusCallback, + taskProgressCallback: taskProgressCallback, + ), + ); + if (counter++ % 3 == 0) { + // To prevent blocking the UI we 'yield' for a few ms after every 3 + // tasks we enqueue + await Future.delayed(const Duration(milliseconds: 50)); + } + } + await Future.wait(taskFutures); // wait for all tasks to complete + _batches.remove(batch); + timer?.cancel(); + return batch; + } + + /// Resets the download worker by cancelling all ongoing tasks for the group + /// + /// Returns the number of tasks canceled + @mustCallSuper + Future reset(String group) async { + final retryCount = tasksWaitingToRetry + .where((task) => task.group == group) + .length; + tasksWaitingToRetry.removeWhere((task) => task.group == group); + final pausedTasks = await getPausedTasks(); + var pausedCount = 0; + for (final task in pausedTasks) { + if (task.group == group) { + await removePausedTask(task.taskId); + pausedCount++; + } + } + final awaitTasksToRemove = awaitTasks.keys + .where((task) => task.group == group) + .toList(); + for (final task in awaitTasksToRemove) { + awaitTasks.remove(task); + } + return retryCount + pausedCount + awaitTasksToRemove.length; + } + + /// Returns a list of all tasks in progress, filtered by [group] unless + /// [allGroups] is true + @mustCallSuper + Future> allTasks( + String group, + bool includeTasksWaitingToRetry, + bool allGroups, + ) async { + assert( + !allGroups || + (group == FileDownloader.defaultGroup && + includeTasksWaitingToRetry == true), + 'If allGroups is true, no other arguments can be set', + ); + final tasks = []; + if (includeTasksWaitingToRetry) { + tasks.addAll( + tasksWaitingToRetry.where( + (task) => allGroups ? true : task.group == group, + ), + ); + } + final pausedTasks = await getPausedTasks(); + tasks.addAll( + pausedTasks.where((task) => allGroups ? true : task.group == group), + ); + return tasks; + } + + /// Cancels ongoing tasks whose taskId is in the list provided with this call + /// + /// Returns true if all cancellations were successful + @mustCallSuper + Future cancelTasksWithIds(Iterable taskIds) async { + final matchingTasksWaitingToRetry = tasksWaitingToRetry + .where((task) => taskIds.contains(task.taskId)) + .toList(growable: false); + final matchingTaskIdsWaitingToRetry = matchingTasksWaitingToRetry + .map((task) => task.taskId) + .toList(growable: false); + // remove tasks waiting to retry from the list so they won't be retried + for (final task in matchingTasksWaitingToRetry) { + tasksWaitingToRetry.remove(task); + processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled)); + processProgressUpdate(TaskProgressUpdate(task, progressCanceled)); + updateNotification(task, null); // remove notification + } + final remainingTaskIds = taskIds.where( + (taskId) => !matchingTaskIdsWaitingToRetry.contains(taskId), + ); + // cancel paused tasks + final pausedTasks = await getPausedTasks(); + final pausedTaskIdsToCancel = pausedTasks + .where((task) => remainingTaskIds.contains(task.taskId)) + .map((e) => e.taskId); + await cancelPausedPlatformTasksWithIds(pausedTasks, pausedTaskIdsToCancel); + // cancel remaining taskIds on the platform + final platformTaskIds = remainingTaskIds + .where((taskId) => !pausedTaskIdsToCancel.contains(taskId)) + .toList(growable: false); + if (platformTaskIds.isEmpty) { + return true; + } + return cancelPlatformTasksWithIds(platformTaskIds); + } + + /// Cancels all tasks, or those in [tasks], or all tasks in group [group] + /// + /// Returns true if all cancellations were successful + Future cancelAll({Iterable? tasks, String? group}) async { + final tasksToCancel = switch ((tasks, group)) { + (final Iterable tasks, null) => tasks, + (null, final String group) => await FileDownloader().allTasks( + group: group, + ), + (null, null) => await FileDownloader().allTasks(), + _ => throw AssertionError( + "Either 'tasks' or 'group' must be provided, or neither, but not both.", + ), + }; + return cancelTasksWithIds(tasksToCancel.map((task) => task.taskId)); + } + + /// Cancel these tasks on the platform + Future cancelPlatformTasksWithIds(List taskIds); + + /// Cancel paused tasks + /// + /// Deletes the associated temp file and emits [TaskStatus.cancel] + Future cancelPausedPlatformTasksWithIds( + List pausedTasks, + Iterable taskIds, + ) async { + for (final taskId in taskIds) { + final task = pausedTasks.firstWhereOrNull( + (element) => element.taskId == taskId, + ); + if (task != null) { + final resumeData = await getResumeData(task.taskId); + if (resumeData != null) { + if (task is ParallelDownloadTask) { + final chunks = List.from( + jsonDecode(resumeData.data, reviver: Chunk.listReviver), + ); + for (final chunk in chunks) { + final tempFilePath = (await getResumeData(chunk.task.taskId)) + ?.tempFilepath; + if (tempFilePath != null) { + try { + await File(tempFilePath).delete(); + } on FileSystemException { + log.fine('Could not delete temp file $tempFilePath'); + } + } + } + } else { + if (!Platform.isIOS) { + final tempFilePath = resumeData.tempFilepath; + try { + await File(tempFilePath).delete(); + } on FileSystemException { + log.fine('Could not delete temp file $tempFilePath'); + } + } + } + } + processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled)); + processProgressUpdate(TaskProgressUpdate(task, progressCanceled)); + updateNotification(task, TaskStatus.canceled); + } + } + } + + /// Returns Task for this taskId, or nil + @mustCallSuper + Future taskForId(String taskId) async { + try { + return tasksWaitingToRetry.where((task) => task.taskId == taskId).first; + } on StateError { + try { + final pausedTasks = await getPausedTasks(); + return pausedTasks.where((task) => task.taskId == taskId).first; + } on StateError { + return null; + } + } + } + + /// Activate tracking for tasks in this group + /// + /// All subsequent tasks in this group will be recorded in persistent storage + /// and can be queried with methods that include 'tracked', e.g. + /// [allTrackedTasks] + /// + /// If [markDownloadedComplete] is true (default) then all tasks that are + /// marked as not yet [TaskStatus.complete] will be set to complete if the + /// target file for that task exists, and will emit [TaskStatus.complete] + /// and [progressComplete] to their registered listener or callback. + /// This is a convenient way to capture downloads that have completed while + /// the app was suspended, provided you have registered your listeners + /// or callback before calling this. + /// + /// NOTE: on Android, when using [UriDownloadTask] the temp file is skipped + /// and therefore the presence of the destination file is not marked as complete. + Future trackTasks(String? group, bool markDownloadedComplete) async { + await ready; // no database operations until ready + trackedGroups.add(group); + if (markDownloadedComplete) { + final records = await database.allRecords(group: group); + var startTime = DateTime.now(); + for (final record in records.where( + (record) => + record.task is DownloadTask && + (!Platform.isAndroid || record.task is! UriDownloadTask) && + record.status != TaskStatus.complete, + )) { + final filePath = await record.task.filePath(); + if (await File(filePath).exists()) { + processStatusUpdate( + TaskStatusUpdate(record.task, TaskStatus.complete), + ); + final updatedRecord = record.copyWith( + status: TaskStatus.complete, + progress: progressComplete, + ); + await database.updateRecord(updatedRecord); + } + if (DateTime.now().difference(startTime).inMilliseconds > 10) { + await Future.delayed(const Duration(milliseconds: 50)); + startTime = DateTime.now(); + } + } + } + } + + /// Return true if we are tracking tasks + bool get isTrackingTasks => trackedGroups.isNotEmpty; + + /// Attempt to pause this [task] + /// + /// Returns true if successful + Future pause(Task task); + + /// Pauses all tasks, or those in [tasks], or all tasks in group [group] + /// + /// Returns list of tasks that were paused + Future> pauseAll({ + Iterable? tasks, + String? group, + }) async { + final tasksToPause = + switch ((tasks, group)) { + (final Iterable tasks, null) => tasks, + (null, final String group) => (await FileDownloader().allTasks( + group: group, + )) as Iterable, + (null, null) => + (await FileDownloader().allTasks()) as Iterable, + _ => throw AssertionError( + "Either 'tasks' or 'group' must be provided, or neither, but not both.", + ), + } + .whereType() + .where((task) => task.allowPause && task.post == null) + .toList(growable: false); + final results = await pauseTaskList(tasksToPause); + return tasksToPause + .asMap() // Convert to a Map (index -> Task) + .entries // Get the entries of the Map (Iterable>) + .where((entry) => results[entry.key] == true) // Filter by success + .map((entry) => entry.value) // Extract the DownloadTask + .toList(growable: false); // Convert back to a List + } + + /// Pause all tasks in this list and return a list of the same length with + /// tasks that were paused marked with true. + /// + /// Platform-specific + Future> pauseTaskList(Iterable tasksToPause); + + /// Attempt to resume this [task] + /// + /// Returns true if successful + @mustCallSuper + Future resume(Task task) async { + await removePausedTask(task.taskId); + if (await getResumeData(task.taskId) != null) { + final currentCompleter = canResumeTask[task]; + if (currentCompleter == null || currentCompleter.isCompleted) { + // create if didn't exist or was completed + canResumeTask[task] = Completer(); + } + return true; + } + return false; + } + + /// Set WiFi requirement globally, based on [requirement]. + /// + /// Affects future tasks and reschedules enqueued, inactive tasks + /// (both downloads and uploads) with the new setting. + /// Reschedules running download tasks if [rescheduleRunningTasks] is true, + /// otherwise leaves those running with their prior setting. + /// Restarts running upload tasks if [alsoRestartUploads] is true (which + /// requires [rescheduleRunningTasks] to be true as well). + Future requireWiFi( + RequireWiFi requirement, + rescheduleRunningTasks, + alsoRestartUploads, + ) => Future.value(true); + + /// Returns the current global setting for requiring WiFi + Future getRequireWiFiSetting() => + Future.value(RequireWiFi.asSetByTask); + + /// Sets the 'canResumeTask' flag for this task + /// + /// Completes the completer already associated with this task + /// if it wasn't completed already + void setCanResume(Task task, bool canResume) { + if (canResumeTask[task]?.isCompleted == false) { + canResumeTask[task]?.complete(canResume); + } + } + + /// Returns a Future that indicates whether this task can be resumed + /// + /// If we have stored [ResumeData] this is true + /// If we have completer then we return its future + /// Otherwise we return false + Future taskCanResume(Task task) async { + if (await getResumeData(task.taskId) != null) { + return true; + } + if (canResumeTask.containsKey(task)) { + return canResumeTask[task]!.future; + } + return false; + } + + /// Stores the resume data + Future setResumeData(ResumeData resumeData) => + _storage.storeResumeData(resumeData); + + /// Retrieve the resume data for this [taskId] + Future getResumeData(String taskId) => + _storage.retrieveResumeData(taskId); + + /// Remove resumeData for this [taskId], or all if null + Future removeResumeData([String? taskId]) => + _storage.removeResumeData(taskId); + + /// Store the paused [task] + Future setPausedTask(Task task) => _storage.storePausedTask(task); + + /// Return a stored paused task with this [taskId], or null if not found + Future getPausedTask(String taskId) => + _storage.retrievePausedTask(taskId); + + /// Return a list of paused [Task] objects + Future> getPausedTasks() => _storage.retrieveAllPausedTasks(); + + /// Remove paused task for this taskId, or all if null + Future removePausedTask([String? taskId]) => + _storage.removePausedTask(taskId); + + /// Retrieve data that was not delivered to Dart + Future> popUndeliveredData(Undelivered dataType); + + /// Clear pause and resume info associated with this [task] + void _clearPauseResumeInfo(Task task) { + canResumeTask.remove(task); + removeResumeData(task.taskId); + removePausedTask(task.taskId); + } + + /// Move the file represented by [filePath] to a shared storage + /// [destination] and potentially a [directory] within that destination. If + /// the [mimeType] is not provided we will attempt to derive it from the + /// [filePath] extension + /// + /// Returns the path to the stored file, or null if not successful + /// If [asUriString] is true, returns the URI of the stored file + /// instead of the filePath + Future moveToSharedStorage( + String filePath, + SharedStorage destination, + String directory, + String? mimeType, { + bool asUriString = false, + }) => Future.value(null); + + /// Returns the filePath to the file represented by [filePath] in shared + /// storage [destination] and potentially a [directory] within that + /// destination. + /// + /// Returns the path to the stored file, or null if not successful + /// If [asUriString] is true, returns the URI of the stored file + /// instead of the filePath + Future pathInSharedStorage( + String filePath, + SharedStorage destination, + String directory, { + bool asUriString = false, + }) => Future.value(null); + + /// Open the file represented by [task] or [filePath] using the application + /// available on the platform. + /// + /// [mimeType] may override the mimetype derived from the file extension, + /// though implementation depends on the platform and may not always work. + /// + /// Returns true if an application was launched successfully + /// + /// Precondition: either task or filename is not null + Future openFile(Task? task, String? filePath, String? mimeType); + + /// Return the platform version as a String + Future platformVersion() => + Future.value(Platform.operatingSystemVersion); + + // Testing methods + + /// Get the duration for a task to timeout - Android only, for testing + @visibleForTesting + Future getTaskTimeout(); + + /// Set forceFailPostOnBackgroundChannel for native downloader + @visibleForTesting + Future setForceFailPostOnBackgroundChannel(bool value); + + /// Test suggested filename based on task and content disposition header + @visibleForTesting + Future testSuggestedFilename( + DownloadTask task, + String contentDisposition, + ); + + // Helper methods + + /// Closes the [updates] stream and re-initializes the [StreamController] + /// such that the stream can be listened to again + Future resetUpdatesStreamController() async { + if (updates.hasListener && !updates.isPaused) { + await updates.close(); + } + updates = StreamController(); + } + + /// Process status update coming from Downloader and emit to listener + /// + /// Also manages retries ([tasksWaitingToRetry] and delay) and pause/resume + /// ([pausedTasks] and [_clearPauseResumeInfo] + void processStatusUpdate(TaskStatusUpdate update) { + // Normal status updates are only sent here when the task is expected + // to provide those. The exception is a .failed status when a task + // has retriesRemaining > 0: those are always sent here, and are + // intercepted to hold the task and reschedule in the near future + final task = update.task; + final isConnectionError = + update.exception is TaskConnectionException || + (update.exception?.description.toLowerCase().contains('socket') == + true) || + (update.exception?.description.toLowerCase().contains('timeout') == + true) || + (update.exception?.description.toLowerCase().contains('network') == + true); + + if (update.status == TaskStatus.failed && + (task.retriesRemaining > 0 || (!isConnected && isConnectionError))) { + _emitStatusUpdate(TaskStatusUpdate(task, TaskStatus.waitingToRetry)); + _emitProgressUpdate(TaskProgressUpdate(task, progressWaitingToRetry)); + + // If device is offline, hold without decrementing retry budget + if (!isConnected) { + tasksWaitingToRetry.add(task); + log.info( + 'TaskId ${task.taskId} failed while offline, holding until network restored', + ); + return; + } + + task.decreaseRetriesRemaining(); + tasksWaitingToRetry.add(task); + final waitTime = Duration( + seconds: 2 << min(task.retries - task.retriesRemaining - 1, 8), + ); + log.finer( + 'TaskId ${task.taskId} failed, waiting ${waitTime.inSeconds}' + ' seconds before retrying. ${task.retriesRemaining}' + ' retries remaining', + ); + Future.delayed(waitTime, () async { + // after delay, resume or enqueue task again if it's still waiting + if (tasksWaitingToRetry.remove(task)) { + if (!((await getResumeData(task.taskId) != null && + await resume(task)) || + await enqueue(task))) { + log.warning( + 'Could not resume/enqueue taskId ${task.taskId} after retry timeout', + ); + _clearPauseResumeInfo(task); + _emitStatusUpdate( + TaskStatusUpdate( + task, + TaskStatus.failed, + TaskException( + 'Could not resume/enqueue taskId${task.taskId} after retry timeout', + ), + ), + ); + _emitProgressUpdate(TaskProgressUpdate(task, progressFailed)); + } + } + }); + } else { + // normal status update + if (update.status == TaskStatus.paused) { + setPausedTask(task); + } + if (update.status.isFinalState) { + _clearPauseResumeInfo(task); + } + if (update.status.isFinalState || update.status == TaskStatus.paused) { + notifyTaskQueues(task); + } + _emitStatusUpdate(update); + } + } + + /// Process progress update coming from Downloader to client listener + void processProgressUpdate(TaskProgressUpdate update) { + switch (update.progress) { + case progressComplete: + case progressFailed: + case progressNotFound: + case progressCanceled: + case progressPaused: + notifyTaskQueues(update.task); + + default: + // no-op + } + _emitProgressUpdate(update); + } + + /// Notify all [taskQueues] that this task has finished + void notifyTaskQueues(Task task) { + for (final taskQueue in taskQueues) { + taskQueue.taskFinished(task); + } + } + + /// Process user tapping on a notification + /// + /// Because a notification tap may cause the app to start from scratch, we + /// allow a few retries with backoff to let the app register a callback + Future processNotificationTap( + Task task, + NotificationType notificationType, + ) async { + groupTransferNotificationTapCallbacks[task.group]?.call( + task, + notificationType, + ); + var retries = 0; + var success = false; + while (retries < 5 && !success) { + final notificationTapCallback = groupNotificationTapCallbacks[task.group]; + if (notificationTapCallback != null) { + notificationTapCallback(task, notificationType); + success = true; + } else { + await Future.delayed( + Duration(milliseconds: 100 * pow(2, retries).round()), + ); + retries++; + } + } + } + + /// Emits the status update for this task to its callback or listener, and + /// update the task in the database + void _emitStatusUpdate(TaskStatusUpdate update) { + final task = update.task; + _updateTaskInDatabase( + task, + status: update.status, + taskException: update.exception, + ); + if (task.providesStatusUpdates) { + groupTransferStatusCallbacks[task.group]?.call(update); + // handle the statusUpdate in order of priority: + // handle [awaitTasks], otherwise try [groupStatusCallbacks], + // otherwise try [updates] listener, otherwise log warning + // for missing handler + if (awaitTasks.containsKey(task)) { + _awaitTaskStatusCallback(update); + } else { + final taskStatusCallback = groupStatusCallbacks[task.group]; + if (taskStatusCallback != null) { + taskStatusCallback(update); + } else { + if (updates.hasListener) { + updates.add(update); + } else { + log.warning( + 'Requested status updates for task ${task.taskId} in ' + 'group ${task.group} but no TaskStatusCallback ' + 'was registered, and there is no listener to the ' + 'updates stream', + ); + } + } + } + } + } + + /// Emit the progress update for this task to its callback or listener, and + /// update the task in the database + void _emitProgressUpdate(TaskProgressUpdate update) { + final task = update.task; + if (task.providesProgressUpdates) { + // handle the progressUpdate in order of priority: + // handle [awaitTasks], otherwise try [groupProgressCallbacks], + // otherwise try [updates] listener, otherwise log warning + // for missing handler + _updateTaskInDatabase( + task, + progress: update.progress, + expectedFileSize: update.expectedFileSize, + ); + groupTransferProgressCallbacks[task.group]?.call(update); + if (awaitTasks.containsKey(task)) { + _awaitTaskProgressCallBack(update); + } else { + final taskProgressCallback = groupProgressCallbacks[task.group]; + if (taskProgressCallback != null) { + taskProgressCallback(update); + } else if (updates.hasListener) { + updates.add(update); + } else { + log.warning( + 'Requested progress updates for task ${task.taskId} in ' + 'group ${task.group} but no TaskProgressCallback ' + 'was registered, and there is no listener to the ' + 'updates stream', + ); + } + } + } + } + + /// Update or remove notification for task + /// + /// If [taskStatusOrNull] is null, removes notification + void updateNotification(Task task, TaskStatus? taskStatusOrNull) {} + + /// Internal callback function for [awaitTasks] that passes the update + /// on to different callbacks + /// + /// The update is passed on to: + /// 1. Task-specific callback, passed as parameter to [enqueueAndAwait] call + /// 2. Short task-specific callback, passed as parameter to call + /// 3. Batch-related callback, if this task is part of a batch operation + /// and is in a final state + /// + /// If the task is in final state, also removes the reference to the + /// task-specific callbacks and completes the completer associated + /// with this task + void _awaitTaskStatusCallback(TaskStatusUpdate statusUpdate) { + final task = statusUpdate.task; + final status = statusUpdate.status; + _shortTaskStatusCallbacks[task.taskId]?.call(status); + _taskStatusCallbacks[task.taskId]?.call(statusUpdate); + if (status.isFinalState) { + if (_batches.isNotEmpty) { + // check if this task is part of a batch + for (final batch in _batches) { + if (batch.tasks.contains(task)) { + batch.results[task] = status; + if (batch.batchProgressCallback != null) { + batch.batchProgressCallback!(batch.numSucceeded, batch.numFailed); + } + break; + } + } + } + _shortTaskStatusCallbacks.remove(task.taskId); + _shortTaskProgressCallbacks.remove(task.taskId); + _taskStatusCallbacks.remove(task.taskId); + _taskProgressCallbacks.remove(task.taskId); + final taskCompleter = awaitTasks.remove(task); + taskCompleter?.complete(statusUpdate); + } + } + + /// Internal callback function that only passes progress updates on + /// to the task-specific progress callback passed as parameter + /// to the [enqueueAndAwait] call + void _awaitTaskProgressCallBack(TaskProgressUpdate progressUpdate) { + _shortTaskProgressCallbacks[progressUpdate.task.taskId]?.call( + progressUpdate.progress, + ); + _taskProgressCallbacks[progressUpdate.task.taskId]?.call(progressUpdate); + } + + /// Insert or update the [TaskRecord] in the tracking database + /// + /// To ensure serialized execution of updates, asynchronously, this + /// adds the parameters to the [_databaseUpdates] stream for later consumption + /// by method [_consumeUpdateTaskInDatabase] + void _updateTaskInDatabase( + Task task, { + TaskStatus? status, + double? progress, + int expectedFileSize = -1, + TaskException? taskException, + }) async { + final completer = Completer(); + _lastDatabaseUpdate = Future.wait([ + _lastDatabaseUpdate, + completer.future, + ]).then((_) {}); + // Status callbacks are synchronous; a flush consumer can attach later. + // Mark the original future handled now, without replacing its error. + _lastDatabaseUpdate.ignore(); + _databaseUpdates.add(( + task, + status, + progress, + expectedFileSize, + taskException, + completer, + )); + } + + /// Execute one database update consumed from the [_databaseUpdates] stream + Future _consumeUpdateTaskInDatabase( + Task task, + TaskStatus? status, + double? progress, + int expectedFileSize, + TaskException? taskException, + ) async { + if (trackedGroups.contains(null) || trackedGroups.contains(task.group)) { + if (status == null && progress != null) { + // update existing record with progress only (provided it's not 'paused') + final existingRecord = await database.recordForId(task.taskId); + if (existingRecord != null && progress != progressPaused) { + await database.updateRecord( + existingRecord.copyWith(progress: progress), + ); + } + return; + } + if (progress == null && status != null) { + // set progress based on status + progress = switch (status) { + .enqueued || .running => 0.0, + .complete => progressComplete, + .notFound => progressNotFound, + .failed => progressFailed, + .canceled => progressCanceled, + .waitingToRetry => progressWaitingToRetry, + .paused => progressPaused, + }; + } + if (status != TaskStatus.paused) { + await database.updateRecord( + TaskRecord(task, status!, progress!, expectedFileSize, taskException), + ); + } else { + // if paused, don't modify the stored progress + final existingRecord = await database.recordForId(task.taskId); + await database.updateRecord( + TaskRecord( + task, + status!, + existingRecord?.progress ?? 0, + expectedFileSize, + taskException, + ), + ); + } + } + } + + /// Destroy - clears callbacks, updates stream and retry queue + /// + /// Clears all queues and references without sending cancellation + /// messages or status updates + @mustCallSuper + void destroy() { + _connectivitySubscription?.cancel(); + tasksWaitingToRetry.clear(); + _batches.clear(); + awaitTasks.clear(); + _shortTaskStatusCallbacks.clear(); + _shortTaskProgressCallbacks.clear(); + _taskStatusCallbacks.clear(); + _taskProgressCallbacks.clear(); + groupStatusCallbacks.clear(); + groupProgressCallbacks.clear(); + groupTransferStatusCallbacks.clear(); + groupTransferProgressCallbacks.clear(); + groupTransferNotificationTapCallbacks.clear(); + notificationConfigs.clear(); + trackedGroups.clear(); + canResumeTask.clear(); + unawaited( + removeResumeData().catchError((e) { + log.fine('Error removing resume data in destroy: $e'); + }), + ); + unawaited( + removePausedTask().catchError((e) { + log.fine('Error removing paused task in destroy: $e'); + }), + ); + resetUpdatesStreamController(); + } +} diff --git a/third_party/background_downloader/lib/src/chunk.dart b/third_party/background_downloader/lib/src/chunk.dart new file mode 100644 index 0000000..c515052 --- /dev/null +++ b/third_party/background_downloader/lib/src/chunk.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'base_downloader.dart'; +import 'file_downloader.dart'; +import 'models.dart'; +import 'task.dart'; + +/// Class representing a chunk of a download and its status +class Chunk { + // key parameters + final String parentTaskId; + final String url; + final String filename; + final int fromByte; // start byte + final int toByte; // end byte + final DownloadTask task; // task to download this chunk + + // state parameters + late TaskStatus status; + late double progress; + + /// Define a chunk by its key parameters, in default state + /// + /// This also generates the [task] to download this chunk, and that + /// task contains the [parentTaskId] and [toByte] and [fromByte] values of the + /// chunk in its [Task.metaData] field as a JSON encoded map + Chunk({ + required Task parentTask, + required this.url, + required this.filename, + required this.fromByte, + required this.toByte, + }) : parentTaskId = parentTask.taskId, + task = DownloadTask( + url: url, + filename: filename, + headers: {...parentTask.headers, 'Range': 'bytes=$fromByte-$toByte'}, + baseDirectory: BaseDirectory.temporary, + group: BaseDownloader.chunkGroup, + updates: updatesBasedOnParent(parentTask), + retries: parentTask.retries, + allowPause: parentTask.allowPause, + priority: parentTask.priority, + requiresWiFi: parentTask.requiresWiFi, + metaData: jsonEncode({ + 'parentTaskId': parentTask.taskId, + 'from': fromByte, + 'to': toByte, + }), + ) { + status = TaskStatus.enqueued; + progress = 0; + } + + /// Creates object from [json] + Chunk.fromJson(Map json) + : parentTaskId = json['parentTaskId'], + url = json['url'], + filename = json['filename'], + fromByte = (json['fromByte'] as num).toInt(), + toByte = (json['toByte'] as num).toInt(), + task = Task.createFromJson(json['task']) as DownloadTask, + status = TaskStatus.values[(json['status'] as num? ?? 0).toInt()], + progress = (json['progress'] as num? ?? 0.0).toDouble(); + + /// Revive `List` from a JSON map in a jsonDecode operation, + /// where each element is a map representing the [Chunk] + static Object? listReviver(Object? key, Object? value) => + key is int ? Chunk.fromJson(value as Map) : value; + + /// Creates JSON map of this object + Map toJson() => { + 'parentTaskId': parentTaskId, + 'url': url, + 'filename': filename, + 'fromByte': fromByte, + 'toByte': toByte, + 'task': task.toJson(), + 'status': status.index, + 'progress': progress, + }; + + /// Return the parentTaskId embedded in the metaData of a chunkTask + static String getParentTaskId(Task task) => + jsonDecode(task.metaData)['parentTaskId'] as String; + + /// Return [Updates] that is based on the [parentTask] + static Updates updatesBasedOnParent(Task parentTask) => + switch (parentTask.updates) { + .none || .status => Updates.status, + .progress || .statusAndProgress => Updates.statusAndProgress, + }; +} + +/// Resume all chunk tasks associated with this [task], and +/// return true if successful, otherwise cancels this [task] +/// which will also cancel all chunk tasks +Future resumeChunkTasks( + ParallelDownloadTask task, + ResumeData resumeData, +) async { + final chunks = List.from( + jsonDecode(resumeData.data, reviver: Chunk.listReviver), + ); + final results = await Future.wait( + // Completed chunks retain their final file/status but have no resume data. + // Resuming them returns false and incorrectly cancels the whole parent. + chunks + .where((chunk) => chunk.status != TaskStatus.complete) + .map((chunk) => FileDownloader().resume(chunk.task)), + ); + if (results.any((result) => result == false)) { + // cancel [ParallelDownloadTask] if any resume did not succeed. + // this will also cancel all chunk tasks + await FileDownloader().cancelTaskWithId(task.taskId); + return false; + } + return true; +} diff --git a/third_party/background_downloader/lib/src/database.dart b/third_party/background_downloader/lib/src/database.dart new file mode 100644 index 0000000..10974d3 --- /dev/null +++ b/third_party/background_downloader/lib/src/database.dart @@ -0,0 +1,318 @@ +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import 'dart:async'; + +import 'base_downloader.dart'; +import 'exceptions.dart'; +import 'models.dart'; +import 'persistent_storage.dart'; +import 'task.dart'; + +/// Persistent database used for tracking task status and progress. +/// +/// Stores [TaskRecord] objects. +/// +/// This object is accessed by the [FileDownloader] and [BaseDownloader] +interface class Database { + static Database? _instance; + late final PersistentStorage _storage; + final StreamController _controller = + StreamController.broadcast(); + final _log = Logger('Database'); + + /// Return the [Database] singleton instance, and creates it if required + factory Database(PersistentStorage persistentStorage) { + _instance ??= Database._internal(persistentStorage); + return _instance!; + } + + Database._internal(PersistentStorage persistentStorage) { + assert(_instance == null); + _storage = persistentStorage; + } + + /// Direct access to the [PersistentStorage] object underlying the + /// database. For testing only + @visibleForTesting + PersistentStorage get storage => _storage; + + /// Stream of [TaskRecord] updates, emitted after a record is updated in + /// [PersistentStorage] + Stream get updates => _controller.stream; + + /// Returns all [TaskRecord] + /// + /// Optionally, specify a [group] to filter by + Future> allRecords({String? group}) async { + final allRecords = await _storage.retrieveAllTaskRecords(); + return group == null + ? allRecords.toList() + : allRecords.where((element) => element.group == group).toList(); + } + + /// Returns all [TaskRecord] older than [age] + /// + /// Optionally, specify a [group] to filter by + Future> allRecordsOlderThan( + Duration age, { + String? group, + }) async { + final allRecordsInGroup = await allRecords(group: group); + final now = DateTime.now(); + return allRecordsInGroup + .where((record) => now.difference(record.task.creationTime) > age) + .toList(); + } + + /// Returns all [TaskRecord] with [TaskStatus] [status] + /// + /// Optionally, specify a [group] to filter by + Future> allRecordsWithStatus( + TaskStatus status, { + String? group, + }) async { + final allRecordsInGroup = await allRecords(group: group); + return allRecordsInGroup + .where((record) => record.status == status) + .toList(); + } + + /// Return [TaskRecord] for this [taskId] or null if not found + Future recordForId(String taskId) => + _storage.retrieveTaskRecord(taskId); + + /// Return list of [TaskRecord] corresponding to the [taskIds] + /// + /// Only records that can be found in the database will be included in the + /// list. TaskIds that cannot be found will be ignored. + Future> recordsForIds(Iterable taskIds) async { + final records = await Future.wait(taskIds.map((id) => recordForId(id))); + return records.whereType().toList(); + } + + /// Delete all records + /// + /// Optionally, specify a [group] to filter by + Future deleteAllRecords({String? group}) async { + if (group == null) { + await _storage.removeTaskRecord(null); + _updateCount = 0; + return; + } + final allRecordsInGroup = await allRecords(group: group); + await deleteRecordsWithIds( + allRecordsInGroup.map((record) => record.taskId), + ); + _updateCount = 0; + } + + /// Delete record with this [taskId] + Future deleteRecordWithId(String taskId) => + deleteRecordsWithIds([taskId]); + + /// Delete records with these [taskIds] + Future deleteRecordsWithIds(Iterable taskIds) async { + await Future.wait( + taskIds.map((taskId) => _storage.removeTaskRecord(taskId)), + ); + } + + int _updateCount = 0; + bool _autoClean = false; + int? _maxRecordCount; + Duration? _maxAge; + bool _isCleaning = false; + bool _waitingToClean = false; + + /// Whether automatic database cleanup is enabled + bool get autoClean => _autoClean; + + /// Clean up the database by removing old records and/or keeping the number + /// of records below a maximum. + /// + /// The [maxRecordCount] determines the maximum number of records to keep. + /// The [maxAge] determines the maximum age of records to keep. + /// If [autoClean] is true, the database will be cleaned automatically + /// every 100th update. + /// + /// If both [maxRecordCount] and [maxAge] are null, the defaults are 500 + /// records and 10 days. + /// + /// The function returns immediately, but the actual cleanup happens + /// asynchronously and is rate-limited to deleting 5 records per second. + void cleanUp({int? maxRecordCount, Duration? maxAge, bool autoClean = true}) { + _autoClean = autoClean; + if (maxRecordCount == null && maxAge == null) { + _maxRecordCount = 500; + _maxAge = const Duration(days: 10); + } else { + _maxRecordCount = maxRecordCount; + _maxAge = maxAge; + } + _cleanDatabase(); + } + + Future _cleanDatabase() async { + if (_isCleaning) { + _waitingToClean = true; + return; + } + _isCleaning = true; + do { + _waitingToClean = false; + try { + final allRecords = await this.allRecords(); + allRecords.sort( + (a, b) => b.task.creationTime.compareTo(a.task.creationTime), + ); // Newest first + + final recordsToDelete = {}; + final now = DateTime.now(); + // Check age + if (_maxAge != null) { + final maxAge = _maxAge!; + recordsToDelete.addAll( + allRecords.where( + (record) => now.difference(record.task.creationTime) > maxAge, + ), + ); + } + // Check count + if (_maxRecordCount != null) { + final count = _maxRecordCount!; + if (allRecords.length > count) { + // Because we sorted newly created first, the ones after 'count' are the oldest + final recordsBeyondCount = allRecords.skip(count); + recordsToDelete.addAll(recordsBeyondCount); + } + } + _log.finest( + 'Database cleanup: ${recordsToDelete.length} out of ${allRecords.length} records to delete', + ); + if (recordsToDelete.isNotEmpty) { + // Rate limit deletion to ~5 per second + for (final record in recordsToDelete) { + await deleteRecordWithId(record.taskId); + await Future.delayed(const Duration(milliseconds: 200)); + } + _log.finest( + 'Database cleanup: ${recordsToDelete.length} records deleted}', + ); + } + } catch (e) { + _log.warning('Error during database cleanup: $e'); + } + } while (_waitingToClean); + _isCleaning = false; + } + + /// Update or insert the record in the database + /// + /// This is used by the [FileDownloader] to track tasks, and should not + /// normally be used by the user of this package + Future updateRecord(TaskRecord record) async { + if (_autoClean) { + _updateCount++; + if (_updateCount % 100 == 0) { + _cleanDatabase(); + } + } + await _storage.storeTaskRecord(record); + if (_controller.hasListener) { + _controller.add(record); + } + } + + /// Destroy the [Database] singleton instance + /// + /// For testing purposes only + void destroy() { + _instance = null; + } +} + +/// Record containing task, task status and task progress. +/// +/// [TaskRecord] represents the state of the task as recorded in persistent +/// storage if [FileDownloader.trackTasks] has been called to activate this. +final class TaskRecord { + final Task task; + final TaskStatus status; + final double progress; + final int expectedFileSize; + final TaskException? exception; + + TaskRecord( + this.task, + this.status, + this.progress, + this.expectedFileSize, [ + this.exception, + ]); + + /// Returns the group collection this record is stored under, which is + /// the [task]'s [Task.group] + String get group => task.group; + + /// Returns the record id, which is the [task]'s [Task.taskId] + String get taskId => task.taskId; + + /// Create [TaskRecord] from [json] + TaskRecord.fromJson(Map json) + : task = Task.createFromJson(json), + status = TaskStatus + .values[(json['status'] as num?)?.toInt() ?? TaskStatus.failed.index], + progress = (json['progress'] as num?)?.toDouble() ?? progressFailed, + expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1, + exception = json['exception'] == null + ? null + : TaskException.fromJson(json['exception']); + + /// Returns JSON map representation of this [TaskRecord] + /// + /// Note the [status], [progress] and [exception] fields are merged into + /// the JSON map representation of the [task] + Map toJson() { + final json = task.toJson(); + json['status'] = status.index; + json['progress'] = progress; + json['expectedFileSize'] = expectedFileSize; + json['exception'] = exception?.toJson(); + return json; + } + + /// Copy with optional replacements. [exception] is always copied + TaskRecord copyWith({ + Task? task, + TaskStatus? status, + double? progress, + int? expectedFileSize, + }) => TaskRecord( + task ?? this.task, + status ?? this.status, + progress ?? this.progress, + expectedFileSize ?? this.expectedFileSize, + exception, + ); + + @override + String toString() => + 'DatabaseRecord{task: $task, status: $status, progress: $progress,' + ' expectedFileSize: $expectedFileSize, exception: $exception}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TaskRecord && + runtimeType == other.runtimeType && + task == other.task && + status == other.status && + progress == other.progress && + expectedFileSize == other.expectedFileSize && + exception == other.exception; + + @override + int get hashCode => + task.hashCode ^ status.hashCode ^ progress.hashCode ^ exception.hashCode; +} diff --git a/third_party/background_downloader/lib/src/desktop/data_isolate.dart b/third_party/background_downloader/lib/src/desktop/data_isolate.dart new file mode 100644 index 0000000..cd8dc51 --- /dev/null +++ b/third_party/background_downloader/lib/src/desktop/data_isolate.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:http/http.dart' as http; + +import '../../background_downloader.dart'; +import 'desktop_downloader.dart'; +import 'download_isolate.dart'; +import 'isolate.dart'; + +/// Do the data task +/// +/// Sends updates via the [sendPort] and can be commanded to cancel via +/// the [messagesToIsolate] queue +Future doDataTask( + DataTask task, + Duration requestTimeout, + SendPort sendPort, +) async { + final client = DesktopDownloader.httpClientForUrl(task.url); + final request = http.Request(task.httpRequestMethod, Uri.parse(task.url)); + request.headers.addAll(task.headers); + request.maxRedirects = defaultMaxRedirects; + if (task.post is String) { + request.body = task.post!; + } + var resultStatus = TaskStatus.failed; + try { + final response = await client.send(request).timeout(requestTimeout); + if (!isCanceled) { + responseHeaders = response.headers; + responseStatusCode = response.statusCode; + extractContentType(response.headers); + responseBody = await responseContent(response); + if (okResponses.contains(response.statusCode)) { + resultStatus = TaskStatus.complete; + } else { + if (response.statusCode == 404) { + resultStatus = TaskStatus.notFound; + } else { + taskException = TaskHttpException( + responseBody?.isNotEmpty == true + ? responseBody! + : response.reasonPhrase ?? 'Invalid HTTP Request', + response.statusCode, + ); + } + } + } + } catch (e) { + logError(task, e.toString()); + setTaskError(e); + } + if (isCanceled) { + // cancellation overrides other results + resultStatus = TaskStatus.canceled; + } + processStatusUpdateInIsolate(task, resultStatus, sendPort); +} diff --git a/third_party/background_downloader/lib/src/desktop/desktop_downloader.dart b/third_party/background_downloader/lib/src/desktop/desktop_downloader.dart new file mode 100644 index 0000000..183b6ce --- /dev/null +++ b/third_party/background_downloader/lib/src/desktop/desktop_downloader.dart @@ -0,0 +1,930 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:async/async.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../base_downloader.dart'; +import '../chunk.dart'; +import '../exceptions.dart'; +import '../file_downloader.dart'; +import '../models.dart'; +import '../task.dart'; +import '../utils.dart'; +import 'isolate.dart'; + +const okResponses = [200, 201, 202, 203, 204, 205, 206]; + +/// Implementation of download functionality for desktop platforms +/// +/// On desktop (MacOS, Linux, Windows) the download and upload are implemented +/// in Dart, as there is no native platform equivalent of URLSession or +/// WorkManager as there is on iOS and Android +final class DesktopDownloader extends BaseDownloader { + static final _log = Logger('DesktopDownloader'); + static const unlimited = 1 << 20; + var maxConcurrent = 10; + var maxConcurrentByHost = unlimited; + var maxConcurrentByGroup = unlimited; + static final DesktopDownloader _singleton = DesktopDownloader._internal(); + final _queue = PriorityQueue(); + final _running = Queue(); // subset that is running + final _resume = {}; + final _isolateSendPorts = + {}; // isolate SendPort for running task + static final Map _clientsCache = {}; + static http.Client? _defaultClient; + static Duration? _requestTimeout; + static var _proxy = {}; // 'address' and 'port' + static var _bypassTLSCertificateValidation = false; + static var _mtlsConfigs = []; + static int _skipExistingFiles = -1; + static String? tempFilePath; + + factory() => _singleton; + + new _internal(); + + @override + Future enqueue(Task task) async { + try { + Uri.decodeFull(task.url); + } catch (e) { + _log.fine('Invalid url: ${task.url} error: $e'); + return false; + } + super.enqueue(task); + _queue.add(task); + processStatusUpdate(TaskStatusUpdate(task, TaskStatus.enqueued)); + _advanceQueue(); + return true; + } + + @override + Future> enqueueAll(Iterable tasks) async { + final results = []; + for (final task in tasks) { + results.add(await enqueue(task)); + } + return results; + } + + /// Advance the queue if it's not empty and there is room in the run queue + void _advanceQueue() { + while (_running.length < maxConcurrent && _queue.isNotEmpty) { + final task = _getNextTask(); + if (task != null) { + _running.add(task); + _executeTask(task) + .catchError((Object e, StackTrace stackTrace) { + // an unexpected error must not leave the task without a final + // status, or keep occupying its slot in the run queue + _log.warning('Error executing taskId ${task.taskId}: $e'); + _log.fine('Stack trace: $stackTrace'); + if (!_running.contains(task)) { + return; // final status was already sent + } + processStatusUpdate( + TaskStatusUpdate( + task, + TaskStatus.failed, + TaskException('Error executing task: $e'), + ), + ); + }) + .whenComplete(() { + _remove(task); + _advanceQueue(); + }); + } else { + return; // if no suitable task, done + } + } + } + + /// Returns a [Task] to run, or null if no suitable task is available + Task? _getNextTask() { + final tasksThatHaveToWait = []; + while (_queue.isNotEmpty) { + final task = _queue.removeFirst(); + if (_numActiveWithHostname(task.hostName) < maxConcurrentByHost && + _numActiveWithGroup(task.group) < maxConcurrentByGroup) { + _queue.addAll(tasksThatHaveToWait); // put back in queue + return task; + } + tasksThatHaveToWait.add(task); + } + _queue.addAll(tasksThatHaveToWait); // put back in queue + return null; + } + + /// Returns number of tasks active with this [hostname] + int _numActiveWithHostname(String hostname) => _running.fold( + 0, + (previousValue, task) => + task.hostName == hostname ? previousValue + 1 : previousValue, + ); + + /// Returns number of tasks active with this [group] + int _numActiveWithGroup(String group) => _running.fold( + 0, + (previousValue, task) => + task.group == group ? previousValue + 1 : previousValue, + ); + + /// Execute this task + /// + /// The task runs on an Isolate, which is sent the task information and + /// which will emit status and progress updates. These updates will be + /// 'forwarded' to the [backgroundChannel] and processed by the + /// [FileDownloader] + Future _executeTask(Task task) async { + // Check if the file should be skipped + // (not possible if the filename will be suggested by the server) + if (task is DownloadTask && _skipExistingFiles != -1 && task.hasFilename) { + final filePath = await task.filePath(); + final file = File(filePath); + if (await file.exists()) { + final fileSize = await file.length(); + // threshold 0 skips any existing file, including an empty one + if (_skipExistingFiles == 0 || + fileSize > _skipExistingFiles * 1024 * 1024) { + processStatusUpdate( + TaskStatusUpdate(task, TaskStatus.complete, null, null, null, 304), + ); + return; + } + } + } + + final resumeData = await getResumeData(task.taskId); + if (resumeData != null) { + await removeResumeData(task.taskId); + } + final isResume = _resume.remove(task) && resumeData != null; + // spawn an isolate to do the task + final receivePort = ReceivePort(); + final errorPort = ReceivePort(); + errorPort.listen((message) { + final exceptionDescription = (message as List).first as String; + final stackTrace = message.last; + logError(task, exceptionDescription); + log.fine('Stack trace: $stackTrace'); + processStatusUpdate( + TaskStatusUpdate( + task, + TaskStatus.failed, + TaskException(exceptionDescription), + ), + ); + receivePort.close(); // also ends listener at the end + }); + final RootIsolateToken? rootIsolateToken = RootIsolateToken.instance; + if (rootIsolateToken == null) { + processStatusUpdate( + TaskStatusUpdate( + task, + TaskStatus.failed, + TaskException('Could not obtain rootIsolateToken'), + ), + ); + return; + } + try { + log.finer('${isResume ? "Resuming" : "Starting"} taskId ${task.taskId}'); + await Isolate.spawn(doTask, ( + rootIsolateToken, + receivePort.sendPort, + ), onError: errorPort.sendPort); + final messagesFromIsolate = StreamQueue(receivePort); + if (!await messagesFromIsolate.hasNext) { + return; // isolate failed before starting, reported via errorPort + } + final sendPort = await messagesFromIsolate.next as SendPort; + sendPort.send(( + task, + resumeData, + isResume, + requestTimeout, + proxy, + bypassTLSCertificateValidation, + _mtlsConfigs, + tempFilePath, + )); + if (_isolateSendPorts.keys.contains(task)) { + // if already registered with null value, cancel immediately + sendPort.send('cancel'); + } + // store the isolate's sendPort so we can send it messages for + // cancellation, and for managing parallel downloads + _isolateSendPorts[task] = sendPort; + // listen for messages sent back from the isolate, until 'done' + // note that the task sent by the isolate may have changed. Therefore, we + // use updatedTask instead of task from here on + while (await messagesFromIsolate.hasNext) { + final message = await messagesFromIsolate.next; + switch (message) { + case 'done': + receivePort.close(); + + case ( + 'statusUpdate', + final Task updatedTask, + final TaskStatus status, + final TaskException? exception, + final String? responseBody, + final Map? responseHeaders, + final int? responseCode, + final String? mimeType, + final String? charSet, + ): + final taskStatusUpdate = TaskStatusUpdate( + updatedTask, + status, + exception, + responseBody, + responseHeaders, + responseCode, + mimeType, + charSet, + ); + if (updatedTask.group != BaseDownloader.chunkGroup) { + if (status.isFinalState) { + _remove(updatedTask); + } + processStatusUpdate(taskStatusUpdate); + } else { + _parallelTaskSendPort(Chunk.getParentTaskId(updatedTask)) + ?.send(taskStatusUpdate); + } + + case ( + 'progressUpdate', + final Task updatedTask, + final double progress, + final int expectedFileSize, + final double downloadSpeed, + final Duration timeRemaining, + ): + final taskProgressUpdate = TaskProgressUpdate( + updatedTask, + progress, + expectedFileSize, + downloadSpeed, + timeRemaining, + ); + if (updatedTask.group != BaseDownloader.chunkGroup) { + processProgressUpdate(taskProgressUpdate); + } else { + _parallelTaskSendPort(Chunk.getParentTaskId(updatedTask)) + ?.send(taskProgressUpdate); + } + + case ('taskCanResume', final bool taskCanResume): + setCanResume(task, taskCanResume); + + case ( + 'resumeData', + final String data, + final int requiredStartByte, + final String? eTag, + ): + try { + await setResumeData( + ResumeData(task, data, requiredStartByte, eTag), + ); + } catch (e) { + _log.warning('Failed to store resume data: $e'); + } + + // from [ParallelDownloadTask] + case ('enqueueChild', final DownloadTask childTask): + await FileDownloader().enqueue(childTask); + + // from [ParallelDownloadTask] + case ('cancelTasksWithId', final List taskIds): + await FileDownloader().cancelTasksWithIds(taskIds); + + // from [ParallelDownloadTask] + case ('pauseTasks', final List tasks): + for (final chunkTask in tasks) { + await FileDownloader().pause(chunkTask); + } + + case ('log', final String logMessage): + _log.finest(logMessage); + + default: + _log.warning( + 'Received message with unknown type ' + '$message from Isolate', + ); + } + } + } finally { + receivePort.close(); + errorPort.close(); + _isolateSendPorts.remove(task); + } + } + + // intercept the status and progress updates for tasks that are 'chunks', i.e. + // part of a [ParallelDownloadTask]. Updates for these tasks are sent to the + // isolate running the [ParallelDownloadTask] instead + + @override + void processStatusUpdate(TaskStatusUpdate update) { + // Regular update if task's group is not chunkGroup + if (update.task.group != FileDownloader.chunkGroup) { + return super.processStatusUpdate(update); + } + // If chunkGroup, send update to task's parent isolate. + // The task's metadata contains taskId of parent + _parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update); + } + + @override + void processProgressUpdate(TaskProgressUpdate update) { + // Regular update if task's group is not chunkGroup + if (update.task.group != FileDownloader.chunkGroup) { + return super.processProgressUpdate(update); + } + // If chunkGroup, send update to task's parent isolate. + // The task's metadata contains taskId of parent + _parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update); + } + + /// Return the [SendPort] for the [ParallelDownloadTask] represented by [taskId] + /// or null if not a [ParallelDownloadTask] or not found + SendPort? _parallelTaskSendPort(String taskId) => _isolateSendPorts.entries + .firstWhereOrNull( + (entry) => + entry.key is ParallelDownloadTask && entry.key.taskId == taskId, + ) + ?.value; + + @override + Future reset(String group) async { + final retryAndPausedTaskCount = await super.reset(group); + final inQueueIds = _queue.unorderedElements + .where((task) => task.group == group) + .map((task) => task.taskId); + final runningIds = _running + .where((task) => task.group == group) + .map((task) => task.taskId); + final taskIds = [...inQueueIds, ...runningIds]; + if (taskIds.isNotEmpty) { + await cancelTasksWithIds(taskIds); + } + return retryAndPausedTaskCount + taskIds.length; + } + + @override + Future> allTasks( + String group, + bool includeTasksWaitingToRetry, + allGroups, + ) async { + final retryAndPausedTasks = await super.allTasks( + group, + includeTasksWaitingToRetry, + allGroups, + ); + if (allGroups) { + return [...retryAndPausedTasks, ..._queue.unorderedElements, ..._running]; + } + final inQueue = _queue.unorderedElements.where( + (task) => task.group == group, + ); + final running = _running.where((task) => task.group == group); + return [...retryAndPausedTasks, ...inQueue, ...running]; + } + + /// Cancels ongoing platform tasks whose taskId is in the list provided + /// + /// Returns true if all cancellations were successful + @override + Future cancelPlatformTasksWithIds(List taskIds) async { + final inQueue = _queue.unorderedElements + .where((task) => taskIds.contains(task.taskId)) + .toList(growable: false); + for (final task in inQueue) { + processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled)); + _remove(task); + } + final running = _running.where((task) => taskIds.contains(task.taskId)); + for (final task in running) { + final sendPort = _isolateSendPorts[task]; + if (sendPort != null) { + sendPort.send('cancel'); + _isolateSendPorts.remove(task); + } else { + // register task for cancellation even if sendPort does not yet exist: + // this will lead to immediate cancellation when the Isolate starts + _isolateSendPorts[task] = null; + } + } + return true; + } + + @override + Future taskForId(String taskId) async { + final task = await super.taskForId(taskId); + if (task != null) { + return task; + } + try { + return _running.where((task) => task.taskId == taskId).first; + } on StateError { + try { + return _queue.unorderedElements + .where((task) => task.taskId == taskId) + .first; + } on StateError { + return null; + } + } + } + + @override + Future pause(Task task) async { + final sendPort = _isolateSendPorts[task]; + if (sendPort != null) { + sendPort.send('pause'); + return true; + } + return false; + } + + @override + Future> pauseTaskList(Iterable tasksToPause) async { + final pauseCalls = tasksToPause.map((task) => pause(task)); + return Future.wait(pauseCalls); + } + + @override + Future resume(Task task) async { + if (await super.resume(task)) { + task = awaitTasks.containsKey(task) + ? awaitTasks.keys.firstWhere( + (awaitTask) => awaitTask.taskId == task.taskId, + ) + : task; + // Enqueue starts _executeTask, which consumes the persisted resume data. + // Retain the parent snapshot before that worker can remove it. + final resumeData = task is ParallelDownloadTask + ? await getResumeData(task.taskId) + : null; + _resume.add(task); + if (await enqueue(task)) { + if (task is ParallelDownloadTask) { + if (resumeData == null) { + return false; + } + return resumeChunkTasks(task, resumeData); + } + return true; + } + } + return false; + } + + @override + Future> popUndeliveredData(Undelivered dataType) => + Future.value({}); + + @override + Future moveToSharedStorage( + String filePath, + SharedStorage destination, + String directory, + String? mimeType, { + bool asUriString = false, + }) async { + final fileUri = Uri.tryParse(filePath); + if (fileUri case Uri(scheme: 'file')) { + filePath = fileUri.toFilePath( + windows: defaultTargetPlatform == TargetPlatform.windows, + ); + } + final destDirectoryPath = await getDestinationDirectoryPath( + destination, + directory, + ); + if (destDirectoryPath == null) { + return null; + } + if (!await Directory(destDirectoryPath).exists()) { + await Directory(destDirectoryPath).create(recursive: true); + } + final fileName = path.basename(filePath); + final destFilePath = path.join(destDirectoryPath, fileName); + try { + await File(filePath).rename(destFilePath); + } on FileSystemException catch (e) { + // rename fails across volumes (e.g. EXDEV), so fall back to copy + delete + _log.fine('Could not rename $filePath ($e), trying copy and delete'); + try { + await File(filePath).copy(destFilePath); + } on FileSystemException catch (e) { + _log.warning('Error moving $filePath to shared storage: $e'); + return null; + } + try { + await File(filePath).delete(); + } on FileSystemException catch (e) { + _log.warning('Could not delete $filePath after copying: $e'); + } + } + return asUriString ? Uri.file(destFilePath).toString() : destFilePath; + } + + @override + Future pathInSharedStorage( + String filePath, + SharedStorage destination, + String directory, { + bool asUriString = false, + }) async { + final destDirectoryPath = await getDestinationDirectoryPath( + destination, + directory, + ); + if (destDirectoryPath == null) { + return null; + } + final fileName = path.basename(filePath); + final destFilePath = path.join(destDirectoryPath, fileName); + return asUriString ? Uri.file(destFilePath).toString() : destFilePath; + } + + /// Returns the path of the destination directory in shared storage, or null + /// + /// Only the .Downloads directory is supported on desktop. + /// The [directory] is appended to the base Downloads directory. + /// The directory at the returned path is not guaranteed to exist. + Future getDestinationDirectoryPath( + SharedStorage destination, + String directory, + ) async { + if (destination != SharedStorage.downloads) { + _log.finer('Desktop only supports .downloads destination'); + return null; + } + final downloadsDirectory = await getDownloadsDirectory(); + if (downloadsDirectory == null) { + _log.warning('Could not obtain downloads directory'); + return null; + } + // remove leading and trailing slashes from [directory] + var cleanDirectory = directory.replaceAll(RegExp(r'^/+'), ''); + cleanDirectory = cleanDirectory.replaceAll(RegExp(r'/$'), ''); + return cleanDirectory.isEmpty + ? downloadsDirectory.path + : path.join(downloadsDirectory.path, cleanDirectory); + } + + @override + Future openFile(Task? task, String? filePath, String? mimeType) async { + final executable = defaultTargetPlatform == TargetPlatform.linux + ? 'xdg-open' + : defaultTargetPlatform == TargetPlatform.macOS + ? 'open' + : 'start'; + filePath ??= await task!.filePath(); + if (!await File(filePath).exists()) { + _log.fine('File to open does not exist: $filePath'); + return false; + } + // On Windows, 'start' treats its first quoted argument as the window + // title, so pass an empty title before the (possibly quoted) filePath + final result = defaultTargetPlatform == TargetPlatform.windows + ? await Process.run('cmd', ['/c', 'start', '', filePath]) + : await Process.run(executable, [filePath], runInShell: true); + if (result.exitCode != 0) { + _log.fine( + 'openFile command $executable returned exit code ${result.exitCode}', + ); + } + return result.exitCode == 0; + } + + @override + Future getTaskTimeout() => Future.value(const Duration(days: 1)); + + @override + Future setForceFailPostOnBackgroundChannel(bool value) { + throw UnimplementedError(); + } + + @override + Future testSuggestedFilename( + DownloadTask task, + String contentDisposition, + ) async { + final h = contentDisposition.isNotEmpty + ? {'Content-disposition': contentDisposition} + : {}; + final t = await taskWithSuggestedFilename(task, h, false); + return t.filename; + } + + @override + dynamic platformConfig({ + dynamic globalConfig, + dynamic androidConfig, + dynamic iOSConfig, + dynamic desktopConfig, + }) => desktopConfig; + + @override + Future<(String, String)> configureItem((String, dynamic) configItem) async { + switch (configItem) { + case (Config.requestTimeout, final Duration? duration): + requestTimeout = duration; + + case (Config.proxy, (final String address, final int port)): + proxy = {'address': address, 'port': port}; + + case (Config.proxy, false): + proxy = {}; + + case (Config.bypassTLSCertificateValidation, final bool bypass): + bypassTLSCertificateValidation = bypass; + + case (Config.mTLS, final MTLSConfig config): + mtlsConfig = config; + + case (Config.mTLS, false): + case (Config.mTLS, null): + resetMtlsConfig(); + + case ( + Config.holdingQueue, + ( + final int? maxConcurrentParam, + final int? maxConcurrentByHostParam, + final int? maxConcurrentByGroupParam, + ), + ): + maxConcurrent = maxConcurrentParam ?? 10; + maxConcurrentByHost = maxConcurrentByHostParam ?? unlimited; + maxConcurrentByGroup = maxConcurrentByGroupParam ?? unlimited; + + case (Config.holdingQueue, Config.never): + case (Config.holdingQueue, false): + maxConcurrent = unlimited; + maxConcurrentByHost = unlimited; + maxConcurrentByGroup = unlimited; + + case (Config.skipExistingFiles, final int value): + _skipExistingFiles = value; + + case (Config.skipExistingFiles, Config.never): + case (Config.skipExistingFiles, false): + _skipExistingFiles = -1; + + case (Config.skipExistingFiles, Config.always): + case (Config.skipExistingFiles, true): + _skipExistingFiles = 0; + + case (Config.tempFilePath, final String path): + tempFilePath = (path.isNotEmpty && path != Config.never) ? path : null; + + case (Config.tempFilePath, null): + case (Config.tempFilePath, false): + tempFilePath = null; + + default: + return ( + configItem.$1, + 'not implemented', + ); // this method did not process this configItem + } + return (configItem.$1, ''); // normal result + } + + /// Sets requestTimeout and recreates HttpClient + static set requestTimeout(Duration? value) { + _requestTimeout = value; + _recreateClient(); + } + + static Duration? get requestTimeout => _requestTimeout; + + /// Sets proxy and recreates HttpClient + /// + /// Value must be dict containing 'address' and 'port' + /// or empty for no proxy + static set proxy(Map value) { + _proxy = value; + _recreateClient(); + } + + static Map get proxy => _proxy; + + /// Set or resets bypass for TLS certificate validation + static set bypassTLSCertificateValidation(bool value) { + _bypassTLSCertificateValidation = value; + _recreateClient(); + } + + static bool get bypassTLSCertificateValidation => + _bypassTLSCertificateValidation; + + /// Set or reset an [MTLSConfig] + static set mtlsConfig(MTLSConfig? config) { + if (config == null || config.isReset) { + resetMtlsConfig(config?.host); + } else { + final configHost = config.host?.toLowerCase(); + _mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == configHost); + _mtlsConfigs.add(config); + _recreateClient(); + } + } + + /// Resets mTLS configuration for [host] or all hosts if [host] is null + static void resetMtlsConfig([String? host]) { + if (host == null) { + _mtlsConfigs.clear(); + } else { + final normHost = host.toLowerCase(); + _mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == normHost); + } + _recreateClient(); + } + + /// Returns active mTLS configurations + static List get mtlsConfigs => List.unmodifiable(_mtlsConfigs); + + /// Sets all mTLS configurations + static set mtlsConfigs(List configs) { + _mtlsConfigs = List.from(configs); + _recreateClient(); + } + + /// Set the HTTP Client to use, with the given parameters + /// + /// This is a convenience method, bundling the [requestTimeout], + /// [proxy], [bypassTLSCertificateValidation] and [mtlsConfigs] + static void setHttpClient( + Duration? requestTimeout, + Map proxy, + bool bypassTLSCertificateValidation, [ + List? mtlsConfigs, + ]) { + _requestTimeout = requestTimeout; + _proxy = proxy; + _bypassTLSCertificateValidation = bypassTLSCertificateValidation; + if (mtlsConfigs != null) { + _mtlsConfigs = List.from(mtlsConfigs); + } + _recreateClient(); + } + + /// Returns an [http.Client] configured for the given [url]. + static http.Client httpClientForUrl(String? url) { + final host = url != null ? Uri.tryParse(url)?.host : null; + return httpClientForHost(host); + } + + /// Returns an [http.Client] configured for the given [host]. + static http.Client httpClientForHost(String? host) { + if (_mtlsConfigs.isEmpty) { + return _defaultClient ??= _createRawClient(null); + } + + final normHost = host?.toLowerCase(); + final matchedConfig = + (normHost != null + ? _mtlsConfigs.firstWhereOrNull( + (c) => c.host?.toLowerCase() == normHost && c.hasCredentials, + ) + : null) ?? + _mtlsConfigs.firstWhereOrNull( + (c) => c.host == null && c.hasCredentials, + ); + + if (matchedConfig == null) { + return _defaultClient ??= _createRawClient(null); + } + + final cacheKey = matchedConfig.host?.toLowerCase() ?? '*'; + return _clientsCache.putIfAbsent( + cacheKey, + () => _createRawClient(matchedConfig), + ); + } + + static const defaultMaxRedirects = 10; + + /// Default HTTP client getter for backward compatibility + static http.Client get httpClient => httpClientForHost(null); + + /// Sets the default HTTP client (primarily for testing) + static set httpClient(http.Client client) { + _defaultClient = client is _RedirectClient + ? client + : _RedirectClient(client); + } + + static http.Client _createRawClient(MTLSConfig? mtlsConfig) { + SecurityContext? securityContext; + if (mtlsConfig != null && mtlsConfig.hasCredentials) { + _log.finest( + 'Creating HTTP client with mTLS configuration for host: ${mtlsConfig.host ?? "all hosts"}', + ); + securityContext = SecurityContext(withTrustedRoots: true); + mtlsConfig.applyToSecurityContext(securityContext); + } + final client = HttpClient(context: securityContext); + client.connectionTimeout = requestTimeout; + client.findProxy = proxy.isNotEmpty + ? (_) => 'PROXY ${_proxy['address']}:${_proxy['port']}' + : null; + client.badCertificateCallback = + bypassTLSCertificateValidation && !kReleaseMode + ? (X509Certificate cert, String host, int port) => true + : null; + return _RedirectClient(IOClient(client)); + } + + /// Recreates the HTTP client instances used for Requests and isolate downloads/uploads + static void _recreateClient() { + for (final client in _clientsCache.values) { + client.close(); + } + _clientsCache.clear(); + _defaultClient?.close(); + _defaultClient = null; + if (bypassTLSCertificateValidation) { + if (kReleaseMode) { + throw ArgumentError( + 'You cannot bypass certificate validation in release mode', + ); + } else { + _log.warning( + 'TLS certificate validation is bypassed. This is insecure and cannot be ' + 'done in release mode', + ); + } + } + _log.finest( + 'Using HTTP client with requestTimeout $_requestTimeout, proxy $_proxy, TLSCertificateBypass = $bypassTLSCertificateValidation, mTLS configs = ${_mtlsConfigs.length}', + ); + } + + @override + void destroy() { + super.destroy(); + _queue.clear(); + _running.clear(); + _isolateSendPorts.clear(); + } + + /// Remove all references to [task] + void _remove(Task task) { + _queue.remove(task); + _running.remove(task); + _isolateSendPorts.remove(task); + } +} + +/// The default maximum number of redirects to follow for HTTP requests (10). +/// +/// On Desktop platforms (macOS, Windows, Linux) and for [FileDownloader.request], +/// up to 10 redirects are followed. Mobile platforms follow their native defaults +/// (iOS allows up to 16, Android allows up to 20). +const defaultMaxRedirects = DesktopDownloader.defaultMaxRedirects; + +/// Wrapper around [http.Client] that raises [http.BaseRequest.maxRedirects] to +/// [defaultMaxRedirects] if it is still at the Dart default of 5. +final class _RedirectClient extends http.BaseClient { + final http.Client _inner; + + _RedirectClient(this._inner); + + @override + Future send(http.BaseRequest request) { + if (request.maxRedirects == 5) { + request.maxRedirects = defaultMaxRedirects; + } + return _inner.send(request); + } + + @override + void close() { + _inner.close(); + super.close(); + } +} diff --git a/third_party/background_downloader/lib/src/desktop/download_isolate.dart b/third_party/background_downloader/lib/src/desktop/download_isolate.dart new file mode 100644 index 0000000..9e66ec4 --- /dev/null +++ b/third_party/background_downloader/lib/src/desktop/download_isolate.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../exceptions.dart'; +import '../models.dart'; +import '../task.dart'; +import '../utils.dart'; +import 'desktop_downloader.dart'; +import 'isolate.dart'; + +var taskRangeStartByte = 0; // Start of the Task's download range +String? eTagHeader; +late DownloadTask downloadTask; // global because filename may change + +/// Execute the download task +/// +/// Sends updates via the [sendPort] and can be commanded to cancel/pause via +/// the [messagesToIsolate] queue +Future doDownloadTask( + DownloadTask task, + ResumeData? resumeData, + bool isResume, + Duration requestTimeout, + String? tempFilePathConfig, + SendPort sendPort, +) async { + // use downloadTask from here on as a 'global' variable in this isolate, + // as we may change the filename of the task + downloadTask = task; + var filePath = await downloadTask.filePath(); + // tempFilePath is taken from [resumeDataString] if this is a resuming task. + // Otherwise, it is a generated full path to the temp directory + final tempFilePath = isResume && resumeData != null + ? resumeData.tempFilepath + : p.join( + tempFilePathConfig ?? (await getTemporaryDirectory()).path, + 'com.bbflight.background_downloader${Random().nextInt(1 << 32).toString()}', + ); + final requiredStartByte = + resumeData?.requiredStartByte ?? 0; // start for resume + final eTag = resumeData?.eTag; + isResume = + isResume && + await determineIfResumeIsPossible(tempFilePath, requiredStartByte); + final client = DesktopDownloader.httpClientForUrl(downloadTask.url); + final request = http.Request( + downloadTask.httpRequestMethod, + Uri.parse(downloadTask.url), + ); + request.headers.addAll(downloadTask.headers); + request.maxRedirects = defaultMaxRedirects; + request.persistentConnection = false; + if (isResume) { + final taskRangeHeader = downloadTask.headers['Range'] ?? ''; + final taskRange = parseRange(taskRangeHeader); + taskRangeStartByte = taskRange.$1; + final resumeRange = (taskRangeStartByte + requiredStartByte, taskRange.$2); + final newRangeString = 'bytes=${resumeRange.$1}-${resumeRange.$2 ?? ""}'; + request.headers['Range'] = newRangeString; + } + switch (downloadTask.postBody) { + case final Uint8List bytes: + request.bodyBytes = bytes; + case final String post: + request.body = post; + } + var resultStatus = TaskStatus.failed; + try { + final response = await client.send(request).timeout(requestTimeout); + if (!isCanceled) { + eTagHeader = response.headers['etag'] ?? response.headers['ETag']; + final acceptRangesHeader = response.headers['accept-ranges']; + final serverAcceptsRanges = + acceptRangesHeader == 'bytes' || response.statusCode == 206; + var taskCanResume = false; + if (downloadTask.allowPause) { + // determine if this task can be paused + taskCanResume = serverAcceptsRanges; + sendPort.send(('taskCanResume', taskCanResume)); + } + isResume = + isResume && response.statusCode == 206; // confirm resume response + if (isResume && (eTagHeader != eTag || eTag?.startsWith('W/') == true)) { + throw TaskException('Cannot resume: ETag is not identical, or is weak'); + } + if (!downloadTask.hasFilename) { + downloadTask = await taskWithSuggestedFilename( + downloadTask, + response.headers, + true, + ); + // update the filePath by replacing the last segment with the new filename + filePath = p.join(p.dirname(filePath), downloadTask.filename); + log.finest( + 'Suggested filename for taskId ${task.taskId}: ${task.filename}', + ); + } + responseHeaders = response.headers; + responseStatusCode = response.statusCode; + extractContentType(response.headers); + if (okResponses.contains(response.statusCode)) { + resultStatus = await processOkDownloadResponse( + filePath, + tempFilePath, + serverAcceptsRanges, + taskCanResume, + isResume, + requestTimeout, + response, + sendPort, + ); + } else { + // not an OK response + responseBody = await responseContent(response); + if (response.statusCode == 404) { + resultStatus = TaskStatus.notFound; + } else { + taskException = TaskHttpException( + responseBody?.isNotEmpty == true + ? responseBody! + : response.reasonPhrase ?? 'Invalid HTTP Request', + response.statusCode, + ); + } + } + } + } catch (e) { + logError(downloadTask, e.toString()); + setTaskError(e); + } + if (isCanceled) { + // cancellation overrides other results + resultStatus = TaskStatus.canceled; + } + processStatusUpdateInIsolate(downloadTask, resultStatus, sendPort); +} + +/// Return true if resume is possible +/// +/// Confirms that file at [tempFilePath] exists and its length equals +/// [requiredStartByte] +Future determineIfResumeIsPossible( + String tempFilePath, + int requiredStartByte, +) async { + if (File(tempFilePath).existsSync()) { + if (await File(tempFilePath).length() == requiredStartByte) { + return true; + } else { + log.fine('Partially downloaded file is corrupted, resume not possible'); + } + } else { + log.fine('Partially downloaded file not available, resume not possible'); + } + return false; +} + +/// Process response with valid response code +/// +/// Performs the actual bytes transfer from response to a temp file, +/// and handles the result of the transfer: +/// - .complete -> copy temp to final file location +/// - .failed -> delete temp file +/// - .paused -> post resume information +Future processOkDownloadResponse( + String filePath, + String tempFilePath, + bool serverAcceptsRanges, + bool taskCanResume, + bool isResume, + Duration requestTimeout, + http.StreamedResponse response, + SendPort sendPort, +) async { + // contentLength is extracted from response header, and if not available + // we attempt to extract from [Task.headers], allowing developer to + // set the content length if already known + final contentLength = getContentLength(response.headers, downloadTask); + isResume = isResume && response.statusCode == 206; + if (isResume && !await prepareResume(response, tempFilePath)) { + deleteTempFile(tempFilePath); + return TaskStatus.failed; + } + var resultStatus = TaskStatus.failed; + var actualTempFilePath = tempFilePath; + IOSink? outStream; + try { + // do the actual download + try { + Directory(p.dirname(actualTempFilePath)).createSync(recursive: true); + outStream = File(actualTempFilePath) + .openWrite(mode: isResume ? FileMode.append : FileMode.write); + } catch (e) { + if (!isResume) { + // Fallback to the target download directory as a hidden file + final targetDir = p.dirname(filePath); + Directory(targetDir).createSync(recursive: true); + actualTempFilePath = p.join(targetDir, '.${p.basename(tempFilePath)}'); + log.info( + 'Standard temporary directory not writeable ($e). ' + 'Falling back to target directory temp file: $actualTempFilePath', + ); + outStream = File(actualTempFilePath).openWrite(mode: FileMode.write); + } else { + rethrow; + } + } + final transferBytesResult = await transferBytes( + response.stream, + outStream, + contentLength, + downloadTask, + sendPort, + requestTimeout, + ); + switch (transferBytesResult) { + case .complete: + // copy file to destination, creating dirs if needed + await outStream.flush(); + final dirPath = p.dirname(filePath); + Directory(dirPath).createSync(recursive: true); + File(actualTempFilePath).copySync(filePath); + resultStatus = TaskStatus.complete; + + case .canceled: + // temp file is deleted in finally, after closing outStream + resultStatus = TaskStatus.canceled; + + case .paused: + if (taskCanResume) { + sendPort.send(( + 'resumeData', + actualTempFilePath, + bytesTotal + startByte, + eTagHeader, + )); + resultStatus = TaskStatus.paused; + } else { + taskException = TaskResumeException( + 'Task was paused but cannot resume', + ); + resultStatus = TaskStatus.failed; + } + + case .failed: + break; + + default: + throw ArgumentError('Cannot process $transferBytesResult'); + } + } catch (e) { + logError(downloadTask, e.toString()); + setTaskError(e); + } finally { + try { + try { + await outStream?.close(); + } catch (_) {} + if (resultStatus == TaskStatus.failed && + serverAcceptsRanges && + (bytesTotal + startByte > 1 << 20 || isResume)) { + // send ResumeData to allow resume after fail + sendPort.send(( + 'resumeData', + actualTempFilePath, + bytesTotal + startByte, + eTagHeader, + )); + } else if (resultStatus != TaskStatus.paused && + File(actualTempFilePath).existsSync()) { + File(actualTempFilePath).deleteSync(); + } + } catch (e) { + logError( + downloadTask, + 'Could not delete temp file $actualTempFilePath: $e', + ); + } + } + return resultStatus; +} + +/// Prepare for resume if possible +/// +/// Returns true if task can continue, false if task failed. +/// Extracts and parses Range headers, and truncates temp file +Future prepareResume( + http.StreamedResponse response, + String tempFilePath, +) async { + final range = response.headers['content-range']; + if (range == null) { + log.fine('Could not process partial response Content-Range'); + taskException = TaskResumeException( + 'Could not process partial response Content-Range', + ); + return false; + } + final contentRangeRegEx = RegExp(r"(\d+)-(\d+)/(\d+)"); + final matchResult = contentRangeRegEx.firstMatch(range); + if (matchResult == null) { + log.fine('Could not process partial response Content-Range $range'); + taskException = TaskResumeException( + 'Could not process ' + 'partial response Content-Range $range', + ); + return false; + } + final start = int.parse(matchResult.group(1) ?? '0'); + final end = int.parse(matchResult.group(2) ?? '0'); + final total = int.parse(matchResult.group(3) ?? '0'); + final tempFile = File(tempFilePath); + final tempFileLength = await tempFile.length(); + log.finest( + 'Resume start=$start, end=$end of total=$total bytes, tempFile = $tempFileLength bytes', + ); + startByte = start - taskRangeStartByte; // relative to start of range + if (startByte > tempFileLength) { + log.fine('Offered range not feasible: $range with startByte $startByte'); + taskException = TaskResumeException( + 'Offered range not feasible: $range with startByte $startByte', + ); + return false; + } + try { + final file = await tempFile.open(mode: FileMode.writeOnlyAppend); + await file.truncate(startByte); + await file.close(); + } on FileSystemException { + log.fine('Could not truncate temp file'); + taskException = TaskResumeException('Could not truncate temp file'); + return false; + } + return true; +} + +/// Delete the temporary file +void deleteTempFile(String tempFilePath) async { + try { + File(tempFilePath).deleteSync(); + } on FileSystemException { + log.fine('Could not delete temp file $tempFilePath'); + } +} + +/// Extract content type from [headers] and set [mimeType] and [charSet] +void extractContentType(Map headers) { + final contentType = headers['content-type']; + if (contentType != null) { + final regEx = RegExp(r'(.*);\s*charset\s*=(.*)'); + final match = regEx.firstMatch(contentType); + if (match != null) { + mimeType = match.group(1); + charSet = match.group(2); + } else { + mimeType = contentType; + } + } +} diff --git a/third_party/background_downloader/lib/src/desktop/isolate.dart b/third_party/background_downloader/lib/src/desktop/isolate.dart new file mode 100644 index 0000000..6eceb25 --- /dev/null +++ b/third_party/background_downloader/lib/src/desktop/isolate.dart @@ -0,0 +1,502 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; + +import 'package:async/async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../exceptions.dart'; +import '../models.dart'; +import '../task.dart'; +import 'data_isolate.dart'; +import 'desktop_downloader.dart'; +import 'download_isolate.dart'; +import 'parallel_download_isolate.dart'; +import 'upload_isolate.dart'; + +/// global variables, unique to this isolate +var bytesTotal = 0; // total bytes read in this download session +var startByte = + 0; // starting position within the original range, used for resume +var lastProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0); +var nextProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0); +var lastProgressUpdate = 0.0; +var bytesTotalAtLastProgressUpdate = 0; + +var networkSpeed = 0.0; // in MB/s +var isPaused = false; +var isCanceled = false; + +// additional parameters for final TaskStatusUpdate +TaskException? taskException; +String? responseBody; +Map? responseHeaders; +int? responseStatusCode; +String? mimeType; // derived from Content-Type header +String? charSet; // derived from Content-Type header + +// logging from isolate is always 'FINEST', as it is sent to +// the [DesktopDownloader] for processing +final log = Logger('FileDownloader'); + +/// Do the task, sending messages back to the main isolate via [sendPort] +/// +/// The first message sent back is a [ReceivePort] that is the command port +/// for the isolate. The first command must be the arguments: task and filePath. +Future doTask((RootIsolateToken, SendPort) isolateArguments) async { + final (rootIsolateToken, sendPort) = isolateArguments; + BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken); + final receivePort = ReceivePort(); + // send the receive port back to the main Isolate + sendPort.send(receivePort.sendPort); + final messagesToIsolate = StreamQueue(receivePort); + // get the arguments list and parse each argument + final ( + Task originalTask, + ResumeData? resumeData, + bool isResume, + Duration? requestTimeout, + Map proxy, + bool bypassTLSCertificateValidation, + List mtlsConfigs, + String? tempFilePathConfig, + ) = await messagesToIsolate.next; + DesktopDownloader.setHttpClient( + requestTimeout, + proxy, + bypassTLSCertificateValidation, + mtlsConfigs, + ); + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((LogRecord rec) { + if (kDebugMode) { + sendPort.send(('log', (rec.message))); + } + }); + // process native callbacks beforeTaskStart, onTaskStart and onAuth + final statusUpdate = await originalTask.options?.beforeTaskStartCallBack + ?.call(originalTask); + if (statusUpdate != null) { + log.fine( + 'TaskId ${originalTask.taskId} interrupted by beforeTaskStart callback', + ); + // set global vars for final TaskStatusUpdate + taskException = statusUpdate.exception; + responseBody = statusUpdate.responseBody; + responseHeaders = statusUpdate.responseHeaders; + responseStatusCode = statusUpdate.responseStatusCode; + processStatusUpdateInIsolate(originalTask, statusUpdate.status, sendPort); + return; + } + final task = await getModifiedTask( + originalTask, + ); // processes onStart and onAuth + // start listener/processor for incoming messages + unawaited(listenToIncomingMessages(task, messagesToIsolate, sendPort)); + processStatusUpdateInIsolate(task, TaskStatus.running, sendPort); + if (!isResume) { + processProgressUpdateInIsolate(task, 0.0, sendPort); + } + if (task.retriesRemaining < 0) { + const message = 'Task has negative retries remaining'; + logError(task, message); + taskException = TaskException(message); + processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort); + } else { + // allow immediate cancel message to come through + await Future.delayed(const Duration(milliseconds: 0)); + await switch (task) { + ParallelDownloadTask() => doParallelDownloadTask( + task, + resumeData, + isResume, + requestTimeout ?? const Duration(seconds: 60), + sendPort, + ), + DownloadTask() => doDownloadTask( + task, + resumeData, + isResume, + requestTimeout ?? const Duration(seconds: 60), + tempFilePathConfig, + sendPort, + ), + UploadTask() => doUploadTask(task, sendPort), + DataTask() => doDataTask( + task, + requestTimeout ?? const Duration(seconds: 60), + sendPort, + ), + _ => throw UnimplementedError(), + }; + } + DesktopDownloader.httpClient.close(); + receivePort.close(); + sendPort.send('done'); // signals end + Isolate.exit(); +} + +/// Listen async to messages to the isolate, and process these +/// +/// Called as unawaited Future, which completes when the [messagesToIsolate] +/// stream is closed +Future listenToIncomingMessages( + Task task, + StreamQueue messagesToIsolate, + SendPort sendPort, +) async { + while (await messagesToIsolate.hasNext) { + final message = await messagesToIsolate.next; + switch (message) { + case 'cancel': + isCanceled = true; // checked in loop elsewhere + if (task is ParallelDownloadTask) { + cancelParallelDownloadTask(task, sendPort); + } + + case 'pause': + isPaused = true; // checked in loop elsewhere + if (task is ParallelDownloadTask) { + pauseParallelDownloadTask(task, sendPort); + } + + // Status and progress updates are incoming from chunk tasks, part of a + // [ParallelDownloadTask]. We update the chunk status/progress and + // determine the aggregate status/progress for the parent task. If changed, + // we process that update for the parent task as we would for a regular + // [DownloadTask]. + // Note that [task] refers to the parent task, whereas [update.task] refers + // to the chunk (child) task + case final TaskStatusUpdate update: + await chunkStatusUpdate(task, update, sendPort); + + case final TaskProgressUpdate update: + chunkProgressUpdate(task, update, sendPort); + } + } +} + +/// Transfer all bytes from [inStream] to [outStream], expecting [contentLength] +/// total bytes +/// +/// Sends updates via the [sendPort] and can be commanded to cancel/pause via +/// the [messagesToIsolate] queue +/// +/// Returns a [TaskStatus] and will throw any exception generated within +/// +/// Note: does not flush or close any streams +Future transferBytes( + Stream> inStream, + StreamSink> outStream, + int contentLength, + Task task, + SendPort sendPort, [ + Duration requestTimeout = const Duration(seconds: 60), +]) async { + if (contentLength == 0) { + contentLength = -1; + } + var resultStatus = TaskStatus.complete; + try { + await outStream.addStream( + inStream + .timeout( + requestTimeout, + onTimeout: (sink) { + taskException = TaskConnectionException('Connection timed out'); + resultStatus = TaskStatus.failed; + sink.close(); // ends the stream + }, + ) + .map((bytes) { + if (isCanceled) { + resultStatus = TaskStatus.canceled; + throw StateError('Canceled'); + } + if (isPaused) { + resultStatus = TaskStatus.paused; + throw StateError('Paused'); + } + bytesTotal += bytes.length; + final progress = min( + (bytesTotal + startByte).toDouble() / (contentLength + startByte), + 0.999, + ); + final now = DateTime.now(); + if (contentLength > 0 && shouldSendProgressUpdate(progress, now)) { + processProgressUpdateInIsolate( + task, + progress, + sendPort, + contentLength + startByte, + ); + lastProgressUpdate = progress; + nextProgressUpdateTime = now.add( + const Duration(milliseconds: 500), + ); + } + return bytes; + }), + ); + } catch (e) { + if (resultStatus == TaskStatus.complete) { + // this was an unintentional error thrown within the stream processing + logError(task, e.toString()); + setTaskError(e); + resultStatus = TaskStatus.failed; + } + } + return resultStatus; +} + +/// Processes a change in status for the [task] +/// +/// Sends status update via the [sendPort], if requested +/// If the task is finished, processes a final progressUpdate update +void processStatusUpdateInIsolate( + Task task, + TaskStatus status, + SendPort sendPort, +) { + final retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0; + // if task is in final state, process a final progressUpdate + // A 'failed' progress update is only provided if + // a retry is not needed: if it is needed, a `waitingToRetry` progress update + // will be generated in the FileDownloader + switch (status) { + case .complete: + processProgressUpdateInIsolate(task, progressComplete, sendPort); + + case .failed when !retryNeeded: + processProgressUpdateInIsolate(task, progressFailed, sendPort); + + case .canceled: + processProgressUpdateInIsolate(task, progressCanceled, sendPort); + + case .notFound: + processProgressUpdateInIsolate(task, progressNotFound, sendPort); + + case .paused: + processProgressUpdateInIsolate(task, progressPaused, sendPort); + + default: + {} + } + final statusUpdate = TaskStatusUpdate( + task, + status, + status == TaskStatus.failed ? taskException ?? TaskException('None') : null, + status.isFinalState ? responseBody : null, + status.isFinalState ? responseHeaders : null, + status == TaskStatus.complete || status == TaskStatus.notFound + ? responseStatusCode + : null, + status.isFinalState ? mimeType : null, + status.isFinalState ? charSet : null, + ); + // Post update if task expects one, or if failed and retry is needed + if (task.providesStatusUpdates || retryNeeded) { + sendPort.send(( + 'statusUpdate', + statusUpdate.task, + statusUpdate.status, + statusUpdate.exception, + statusUpdate.responseBody, + statusUpdate.responseHeaders, + statusUpdate.responseStatusCode, + statusUpdate.mimeType, + statusUpdate.charSet, + )); + } + if (status.isFinalState) { + task.options?.onTaskFinishedCallBack?.call(statusUpdate); + } +} + +/// Processes a progress update for the [task] +/// +/// Sends progress update via the [sendPort], if requested +void processProgressUpdateInIsolate( + Task task, + double progress, + SendPort sendPort, [ + int expectedFileSize = -1, +]) { + if (task.providesProgressUpdates) { + if (progress > 0 && progress < 1) { + // calculate download speed and time remaining + final now = DateTime.now(); + final timeSinceLastUpdate = now.difference(lastProgressUpdateTime); + lastProgressUpdateTime = now; + if (task is ParallelDownloadTask) { + // approximate based on aggregate progress + bytesTotal = (progress * expectedFileSize).floor(); + } + final bytesSinceLastUpdate = bytesTotal - bytesTotalAtLastProgressUpdate; + bytesTotalAtLastProgressUpdate = bytesTotal; + final currentNetworkSpeed = timeSinceLastUpdate.inHours > 0 + ? -1.0 + : bytesSinceLastUpdate / timeSinceLastUpdate.inMicroseconds; + networkSpeed = switch (currentNetworkSpeed) { + -1.0 => -1.0, + _ when networkSpeed == -1.0 => currentNetworkSpeed, + _ => (networkSpeed * 3 + currentNetworkSpeed) / 4.0, + }; + final remainingBytes = (1 - progress) * expectedFileSize; + final timeRemaining = networkSpeed == -1.0 || expectedFileSize < 0 + ? const Duration(seconds: -1) + : Duration(microseconds: (remainingBytes / networkSpeed).round()); + sendPort.send(( + 'progressUpdate', + task, + progress, + expectedFileSize, + networkSpeed, + timeRemaining, + )); + } else { + // no download speed or time remaining + sendPort.send(( + 'progressUpdate', + task, + progress, + expectedFileSize, + -1.0, + const Duration(seconds: -1), + )); + } + } +} + +/// Returns a [task] that may be modified through callbacks +/// +/// Callbacks would be attached to the task via its [Task.options] property, and if +/// present will be invoked by starting a taskDispatcher on a background isolate, then +/// sending the callback request via the MethodChannel +Future getModifiedTask(Task task) async { + Task? authTask; + final auth = task.options?.auth; + if (auth != null) { + // refresh token if needed + if (auth.isTokenExpired() && auth.onAuthCallback != null) { + authTask = await auth.onAuthCallback?.call(task); + } + authTask ??= task; // either original or newly authorized + final newAuth = authTask.options?.auth; + if (newAuth == null) { + // no auth object on authorized task + return authTask; + } + // insert query parameters and headers + final uri = newAuth.addOrUpdateQueryParams( + url: authTask.url, + queryParams: newAuth.getAccessQueryParams(), + ); + final headers = {...authTask.headers, ...newAuth.getAccessHeaders()}; + authTask = authTask.copyWith(url: uri.toString(), headers: headers); + } + authTask = authTask ??= task; + final modifiedTask = await authTask.options?.onTaskStartCallBack?.call( + authTask, + ); + return modifiedTask ?? authTask; +} + +// The following functions are related to multipart uploads and are +// by and large copied from the dart:http package. Similar implementations +// in Kotlin and Swift are translations of the same code + +/// Returns the multipart entry for one field name/value pair +String fieldEntry(String name, String value) => + '--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed'; + +/// Returns the header string for a field. +/// +/// The return value is guaranteed to contain only ASCII characters. +String headerForField(String name, String value) { + var header = 'content-disposition: form-data; name="${browserEncode(name)}"'; + if (isJsonString(value)) { + header = + '$header\r\n' + 'content-type: application/json; charset=utf-8'; + } else if (!isPlainAscii(value)) { + header = + '$header\r\n' + 'content-type: text/plain; charset=utf-8\r\n' + 'content-transfer-encoding: binary'; + } + return '$header\r\n\r\n'; +} + +/// A regular expression that matches strings that are composed entirely of +/// ASCII-compatible characters. +final _asciiOnly = RegExp(r'^[\x00-\x7F]+$'); + +/// A regular expression that matches strings that start and end with JSON object +/// or JSON array characters, and that therefore are presumed to be in JSON +/// format +final _jsonString = RegExp(r'^\s*(\{.*\}|\[.*\])\s*$'); + +final _newlineRegExp = RegExp(r'\r\n|\r|\n'); + +/// Returns whether [string] is composed entirely of ASCII-compatible +/// characters. +bool isPlainAscii(String string) => _asciiOnly.hasMatch(string); + +/// Returns whether [string] is a JSON formatted string, based on simple +/// test of start and end characters {} or [] +bool isJsonString(String string) => _jsonString.hasMatch(string); + +/// Encode [value] in the same way browsers do. +String browserEncode(String value) => + // http://tools.ietf.org/html/rfc2388 mandates some complex encodings for + // field names and file names, but in practice user agents seem not to + // follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as + // `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII + // characters). We follow their behavior. + value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22'); + +/// Returns the length of the [string] in bytes when utf-8 encoded +int lengthInBytes(String string) => utf8.encode(string).length; + +/// Log an error for this task +void logError(Task task, String error) { + log.fine('Error for taskId ${task.taskId}: $error'); +} + +/// Set the [taskException] variable based on error e +void setTaskError(dynamic e) { + taskException = switch (e) { + HttpException() || + TimeoutException() => TaskConnectionException(e.toString()), + IOException() => TaskFileSystemException(e.toString()), + TaskException() => e, + _ => TaskException(e.toString()), + }; +} + +/// Return the response's content as a String, or null if unable +Future responseContent(http.StreamedResponse response) { + try { + return response.stream.bytesToString(); + } catch (e) { + log.fine( + 'Could not read response content from httpResponseCode ${response.statusCode}: $e', + ); + return Future.value(null); + } +} + +/// Returns true if [currentProgress] > [lastProgressUpdate] + 2% and +/// [now] > [nextProgressUpdateTime], or if there was progress and +/// [now] > [nextProgressUpdateTime] + 2 seconds +bool shouldSendProgressUpdate(double currentProgress, DateTime now) => + (currentProgress - lastProgressUpdate > 0.02 && + now.isAfter(nextProgressUpdateTime)) || + (currentProgress > lastProgressUpdate && + now.isAfter(nextProgressUpdateTime.add(const Duration(seconds: 2)))); diff --git a/third_party/background_downloader/lib/src/desktop/parallel_download_isolate.dart b/third_party/background_downloader/lib/src/desktop/parallel_download_isolate.dart new file mode 100644 index 0000000..ab20c42 --- /dev/null +++ b/third_party/background_downloader/lib/src/desktop/parallel_download_isolate.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; + +import 'package:collection/collection.dart'; + +import '../chunk.dart'; +import '../exceptions.dart'; +import '../models.dart'; +import '../task.dart'; +import '../utils.dart'; +import 'desktop_downloader.dart'; +import 'download_isolate.dart'; +import 'isolate.dart'; + +/// A [ParallelDownloadTask] pings the server to get the content-length of the +/// download, then creates a list of [Chunk]s, each representing a portion +/// of the download. Each chunk-task has its group set to 'chunk' and +/// has the taskId of the parent [ParallelDownloadTask] in its +/// [Task.metaData] field. +/// The isolate sends 'enqueue' messages back to the [DesktopDownloader] to +/// start each chunk-task, just like any other download task. +/// Messages with group 'chunk' are intercepted in the [DesktopDownloader], +/// where the sendPort for the isolate running the parent task is +/// looked up, and the update is sent to the isolate via that sendPort. +/// In the isolate, the update is processed and the new status/progress +/// of the [ParallelDownloadTask] is determined. If the status/progress has +/// changed, an update is sent and the status is processed (e.g., a complete +/// status triggers the piecing together of the downloaded file from +/// its chunk pieces). +/// +/// Similarly, pause and cancel commands are sent to all chunk tasks before +/// updating the status of the parent [ParallelDownloadTask] + +late ParallelDownloadTask parentTask; +var chunks = []; // chunks associated with this download +var lastTaskStatus = TaskStatus.running; +Completer parallelTaskStatusUpdateCompleter = Completer(); +var parallelDownloadContentLength = -1; + +/// Execute the parallel download task +/// +/// Sends updates via the [sendPort] and can be commanded to cancel/pause via +/// the [messagesToIsolate] queue. +/// +/// If [isResume] is false, we create [Chunk]s and enqueue each of its tasks +/// by sending a message to the [DesktopDownloader], then wait for the +/// completion of [parallelTaskStatusUpdateCompleter] +/// +/// If [isResume] is true, [resumeData] contains the json encoded [chunks] list, +/// and the associated chunk [DownloadTask] tasks will be started by the +/// [DesktopDownloader], so we just wait for completion of +/// [parallelTaskStatusUpdateCompleter] +/// +/// Incoming messages from the [DesktopDownloader] are received in the +/// [listenToIncomingMessages] function +Future doParallelDownloadTask( + ParallelDownloadTask task, + ResumeData? resumeData, + bool isResume, + Duration requestTimeout, + SendPort sendPort, +) async { + parentTask = task; + if (!isResume) { + // start the download by creating [Chunk]s and enqueuing chunk tasks + final response = await DesktopDownloader.httpClientForUrl(task.url) + .head(Uri.parse(task.url), headers: task.headers) + .timeout(requestTimeout); + responseHeaders = response.headers; + responseStatusCode = response.statusCode; + if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) { + // get suggested filename if needed, and change task and parentTask + if (!task.hasFilename) { + task = (await taskWithSuggestedFilename( + task, + response.headers, + true, + )) as ParallelDownloadTask; + parentTask = task; + log.finest( + 'Suggested filename for taskId ${task.taskId}: ${task.filename}', + ); + } + extractContentType(response.headers); + chunks = createChunks(task, response.headers); + for (final chunk in chunks) { + // Ask main isolate to enqueue the child task. Updates related to the child + // will be sent to this isolate (the child's metaData contains the parent taskId). + sendPort.send(('enqueueChild', chunk.task)); + } + // wait for all chunk tasks to complete + final statusUpdate = await parallelTaskStatusUpdateCompleter.future; + processStatusUpdateInIsolate(task, statusUpdate.status, sendPort); + } else { + log.fine( + 'TaskId ${task.taskId}: Invalid server response code ${response.statusCode}', + ); + // not an OK response + responseBody = response.body; + if (response.statusCode == 404) { + processStatusUpdateInIsolate(task, TaskStatus.notFound, sendPort); + } else { + taskException = TaskHttpException( + responseBody?.isNotEmpty == true + ? responseBody! + : response.reasonPhrase ?? 'Invalid HTTP Request', + response.statusCode, + ); + processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort); + } + } + } else { + // resume: reconstruct [chunks] and wait for all chunk tasks to complete + chunks = List.from( + jsonDecode(resumeData!.data, reviver: Chunk.listReviver), + ); + parallelDownloadContentLength = chunks.fold( + 0, + (previousValue, chunk) => + previousValue + chunk.toByte - chunk.fromByte + 1, + ); + final statusUpdate = await parallelTaskStatusUpdateCompleter.future; + processStatusUpdateInIsolate(task, statusUpdate.status, sendPort); + } +} + +/// Process incoming [update] for a chunk, within the [ParallelDownloadTask] +/// represented by [task] +Future chunkStatusUpdate( + Task task, + TaskStatusUpdate update, + SendPort sendPort, +) async { + final chunkTask = update.task; + // first check for fail -> retry + if (update.status == TaskStatus.failed && chunkTask.retriesRemaining > 0) { + chunkTask.decreaseRetriesRemaining(); + final waitTime = Duration( + seconds: 2 << min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8), + ); + log.finer( + 'Chunk task with taskId ${chunkTask.taskId} failed, waiting ${waitTime.inSeconds}' + ' seconds before retrying. ${chunkTask.retriesRemaining}' + ' retries remaining', + ); + Future.delayed(waitTime, () async { + // after delay, resume or enqueue task again if it's still waiting + sendPort.send(('enqueueChild', chunkTask)); + }); + } else { + // no retry + final newStatusUpdate = updateChunkStatus(update); + switch (newStatusUpdate) { + case TaskStatus.complete: + final result = await stitchChunks(); + parallelTaskStatusUpdateCompleter.complete( + TaskStatusUpdate( + task, + result, + null, + responseBody, + responseHeaders, + responseStatusCode, + ), + ); + + case TaskStatus.failed: + taskException = update.exception; + responseBody = update.responseBody; + cancelAllChunkTasks(sendPort); + parallelTaskStatusUpdateCompleter.complete( + TaskStatusUpdate( + task, + TaskStatus.failed, + taskException, + responseBody, + responseHeaders, + responseStatusCode, + ), + ); + + case TaskStatus.notFound: + responseBody = update.responseBody; + cancelAllChunkTasks(sendPort); + parallelTaskStatusUpdateCompleter.complete( + TaskStatusUpdate( + task, + TaskStatus.notFound, + null, + responseBody, + responseHeaders, + responseStatusCode, + ), + ); + + default: + // ignore all other status updates, including null + break; + } + } +} + +/// Update the status for this chunk, and return the status for the parent task +/// as derived from the sum of the child tasks, or null if undefined +/// +/// The updates are received from the [DeskTopDownloader], which intercepts +/// status updates for the [chunkGroup]. Not all regular statuses are passed on +TaskStatus? updateChunkStatus(TaskStatusUpdate update) { + final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task); + if (chunk == null) { + return null; // chunk is not part of this parent task + } + chunk.status = update.status; + final newStatusUpdate = parentTaskStatus(); + if ((newStatusUpdate != null && newStatusUpdate != lastTaskStatus)) { + lastTaskStatus = newStatusUpdate; + return newStatusUpdate; + } + return null; +} + +/// Returns the [TaskStatus] for the parent of this chunk, as derived from +/// the 'sum' of the child tasks, or null if undetermined +/// +/// The updates are received from the [DeskTopDownloader], which intercepts +/// status updates for the [chunkGroup] +TaskStatus? parentTaskStatus() { + final failed = chunks.firstWhereOrNull( + (chunk) => chunk.status == TaskStatus.failed, + ); + if (failed != null) { + return TaskStatus.failed; + } + final notFound = chunks.firstWhereOrNull( + (chunk) => chunk.status == TaskStatus.notFound, + ); + if (notFound != null) { + return TaskStatus.notFound; + } + final allComplete = chunks.every( + (chunk) => chunk.status == TaskStatus.complete, + ); + if (allComplete) { + return TaskStatus.complete; + } + return null; +} + +/// Process incoming [update] for a chunk, within the [ParallelDownloadTask] +/// represented by [task] +void chunkProgressUpdate( + Task task, + TaskProgressUpdate update, + SendPort sendPort, +) { + // update of child task of a [ParallelDownloadTask], only for regular + // progress updates + final now = DateTime.now(); + if (update.progress > 0 && update.progress < 1) { + final parentProgressUpdate = updateChunkProgress(update); + if (parentProgressUpdate != null && + shouldSendProgressUpdate(parentProgressUpdate, now)) { + processProgressUpdateInIsolate( + task, + parentProgressUpdate, + sendPort, + parallelDownloadContentLength, + ); + lastProgressUpdate = parentProgressUpdate; + nextProgressUpdateTime = now.add(const Duration(milliseconds: 500)); + } + } +} + +/// Update the progress for this chunk, and return the progress for the parent +/// task as derived from the sum of the child tasks, or null if undefined +/// +/// The updates are received from the [DeskTopDownloader], which intercepts +/// progress updates for the [chunkGroup]. +/// Only true progress updates (in range 0-1) are passed on to this method +double? updateChunkProgress(TaskProgressUpdate update) { + final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task); + if (chunk == null) { + return null; // chunk is not part of this parent task + } + chunk.progress = update.progress; + return parentTaskProgress(); +} + +/// Returns the progress for the parent of this chunk, as derived +/// from the 'sum' of the child tasks +/// +/// The updates are received from the [DeskTopDownloader], which intercepts +/// progress updates for the [chunkGroup]. +/// Only true progress updates (in range 0-1) are passed on to this method, +/// so we just calculate the average progress +double parentTaskProgress() { + final avgProgress = + chunks.fold( + 0.0, + (previousValue, chunk) => previousValue + chunk.progress, + ) / + chunks.length; + return avgProgress; +} + +/// Cancel this [ParallelDownloadTask] +void cancelParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) { + cancelAllChunkTasks(sendPort); + parallelTaskStatusUpdateCompleter.complete( + TaskStatusUpdate(task, TaskStatus.canceled), + ); +} + +/// Cancel the tasks associated with each chunk +/// +/// Accomplished by sending list of taskIds to cancel to the +/// [DesktopDownloader] +void cancelAllChunkTasks(SendPort sendPort) { + sendPort.send(( + 'cancelTasksWithId', + chunks.map((e) => e.task.taskId).toList(), + )); +} + +/// Pause this [ParallelDownloadTask] +/// +/// Because each [Chunk] is a [DownloadTask], each is paused +/// and generates its own [ResumeData]. +/// [ResumeData] for a [ParallelDownloadTask] is the list of [Chunk]s, +/// including their status and progress, json encoded. +/// To resume, we need to recreate the list of [Chunk]s and pass this with +/// the resumed [ParallelDownloadTask], then resume each of the +/// [DownloadTask]s. This is done in [DesktopDownloader.resume] +void pauseParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) { + pauseAllChunkTasks(sendPort); + sendPort.send(('resumeData', jsonEncode(chunks), -1, null)); + parallelTaskStatusUpdateCompleter.complete( + TaskStatusUpdate(task, TaskStatus.paused), + ); +} + +/// Pause the tasks associated with each chunk +/// +/// Accomplished by sending list of tasks to pause to the +/// [DesktopDownloader] +void pauseAllChunkTasks(SendPort sendPort) { + sendPort.send(('pauseTasks', chunks.map((e) => e.task).toList())); +} + +/// Stitch all chunks together into one file, per the [parentTask] +Future stitchChunks() async { + IOSink? outStream; + StreamSubscription? subscription; + try { + final outFile = File(await parentTask.filePath()); + if (await outFile.exists()) { + await outFile.delete(); + } + outStream = outFile.openWrite(); + for (final chunk in chunks.sorted((a, b) => a.fromByte - b.fromByte)) { + final inFile = File(await chunk.task.filePath()); + if (!await inFile.exists()) { + throw const FileSystemException('Missing chunk file'); + } + final inStream = inFile.openRead(); + final doneCompleter = Completer(); + subscription = inStream.listen( + (bytes) { + outStream?.add(bytes); + }, + onDone: () => doneCompleter.complete(true), + onError: (error) { + logError(parentTask, e.toString()); + setTaskError(error); + doneCompleter.complete(false); + }, + ); + final success = await doneCompleter.future; + if (!success) { + return TaskStatus.failed; + } + subscription.cancel(); + await inFile.delete(); + } + await outStream.flush(); + } catch (e) { + logError(parentTask, e.toString()); + setTaskError(e); + return TaskStatus.failed; + } finally { + await outStream?.close(); + subscription?.cancel(); + for (final chunk in chunks) { + try { + final file = File(await chunk.task.filePath()); + await file.delete(); + } on FileSystemException { + // ignore + } + } + } + return TaskStatus.complete; +} + +/// Returns a list of chunk information for this task, and sets +/// [parallelDownloadContentLength] to the total length of the download +/// +/// Throws a StateError if any information is missing, which should lead +/// to a failure of the [ParallelDownloadTask] +List createChunks( + ParallelDownloadTask task, + Map headers, +) { + try { + final numChunks = task.urls.length * task.chunks; + final contentLength = getContentLength(headers, task); + if (contentLength <= 0) { + throw StateError( + 'Server does not provide content length - cannot chunk download. ' + 'If you know the length, set Range or Known-Content-Length header', + ); + } + parallelDownloadContentLength = contentLength; + try { + headers.entries.firstWhere( + (element) => + element.key.toLowerCase() == 'accept-ranges' && + element.value == 'bytes', + ); + } on StateError { + throw StateError('Server does not accept ranges - cannot chunk download'); + } + final chunkSize = (contentLength / numChunks).ceil(); + return [ + for (var i = 0; i < numChunks; i++) + Chunk( + parentTask: task, + url: task.urls[i % task.urls.length], + filename: Random().nextInt(1 << 32).toString(), + fromByte: i * chunkSize, + toByte: min(i * chunkSize + chunkSize - 1, contentLength - 1), + ), + ]; + } on StateError { + throw StateError( + 'Server does not provide content length - cannot chunk download. ' + 'If you know the length, set Range or Known-Content-Length header', + ); + } +} diff --git a/third_party/background_downloader/lib/src/desktop/upload_isolate.dart b/third_party/background_downloader/lib/src/desktop/upload_isolate.dart new file mode 100644 index 0000000..2332b5f --- /dev/null +++ b/third_party/background_downloader/lib/src/desktop/upload_isolate.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:http/http.dart' as http; +import 'package:mime/mime.dart'; +import 'package:path/path.dart' as p; + +import '../exceptions.dart'; +import '../models.dart'; +import '../task.dart'; +import 'desktop_downloader.dart'; +import 'isolate.dart'; + +const boundary = '-----background_downloader-akjhfw281onqciyhnIk'; +const lineFeed = '\r\n'; + +/// Do the binary or multi-part upload task +/// +/// Sends updates via the [sendPort] and can be commanded to cancel via +/// the [messagesToIsolate] queue +Future doUploadTask(UploadTask task, SendPort sendPort) async { + final (updatedTask, resultStatus) = task.post == 'binary' + ? await binaryUpload(task, sendPort) + : await multipartUpload(task, sendPort); + processStatusUpdateInIsolate(updatedTask, resultStatus, sendPort); +} + +/// Do the binary upload and return the TaskStatus +/// +/// Content-Disposition header will be: +/// - set to 'attachment = "filename"' if the task.headers field does not contain +/// an entry for 'Content-Disposition' +/// - not set at all (i.e. omitted) if the task.headers field contains an entry +/// for 'Content-Disposition' with the value '' (an empty string) +/// - set to the value of task.headers['Content-Disposition'] in all other cases +/// +/// The mime-type will be set to [Task.mimeType] +/// +/// Sends updates via the [sendPort] and can be commanded to cancel via +/// the [messagesToIsolate] queue +Future<(Task, TaskStatus)> binaryUpload( + UploadTask task, + SendPort sendPort, +) async { + final filePath = await task.filePath(); + final inFile = File(filePath); + if (!inFile.existsSync()) { + final message = 'File to upload does not exist: $filePath'; + logError(task, message); + taskException = TaskFileSystemException(message); + return (task, TaskStatus.failed); + } + final fileSize = inFile.lengthSync(); + if (fileSize == 0) { + final message = 'File $filePath has 0 length'; + logError(task, message); + taskException = TaskFileSystemException(message); + return (task, TaskStatus.failed); + } + var resultStatus = TaskStatus.failed; + try { + // Extract Range header information, if present, for partial upload + int start = 0; + int end = fileSize - 1; // Default to the whole file + if (task.headers.containsKey('Range')) { + final rangeHeader = task.headers['Range']!; + final match = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(rangeHeader); + if (match != null) { + start = int.parse(match.group(1)!); + if (match.group(2)!.isNotEmpty) { + end = int.parse(match.group(2)!); + } + } else { + final message = 'Invalid Range header $rangeHeader'; + logError(task, message); + taskException = TaskException(message); + return (task, TaskStatus.failed); + } + task.headers.remove('Range'); // not passed on to server + } + if (task case UriUploadTask(fileUri: final fileUri) + when fileUri != null && task.filename.isEmpty) { + // for UriTasks without a filename, derive it from the Uri + task = task.copyWith(filename: fileUri.pathSegments.last); + } + final contentLength = end - start + 1; + final client = DesktopDownloader.httpClientForUrl(task.url); + final request = http.StreamedRequest( + task.httpRequestMethod, + Uri.parse(task.url), + ); + request.maxRedirects = defaultMaxRedirects; + request.headers.addAll(task.headers); + request.contentLength = contentLength; + request.headers['Content-Type'] = task.mimeType; + final taskContentDisposition = + task.headers['Content-Disposition'] ?? + task.headers['content-disposition']; + if (taskContentDisposition != '') { + request.headers['Content-Disposition'] = + taskContentDisposition ?? + 'attachment; filename="${Uri.encodeComponent(task.filename)}"'; + } else { + request.headers.remove('Content-Disposition'); + } + request.persistentConnection = false; + // initiate the request and handle completion async + final requestCompleter = Completer(); + var transferBytesResult = TaskStatus.failed; + client.send(request).then((response) async { + // request completed, so send status update and finish + resultStatus = + transferBytesResult == TaskStatus.complete && + !okResponses.contains(response.statusCode) + ? TaskStatus.failed + : transferBytesResult; + responseBody = await responseContent(response); + responseHeaders = response.headers; + responseStatusCode = response.statusCode; + taskException ??= TaskHttpException( + responseBody?.isNotEmpty == true + ? responseBody! + : response.reasonPhrase ?? 'Invalid HTTP response', + response.statusCode, + ); + if (response.statusCode == 404) { + resultStatus = TaskStatus.notFound; + } + requestCompleter.complete(); + }); + // send the bytes to the request sink + final inStream = inFile.openRead(start, end + 1); + transferBytesResult = await transferBytes( + inStream, + request.sink, + fileSize, + task, + sendPort, + ); + request.sink.close(); // triggers request completion, handled above + if (isCanceled) { + // cancellation overrides other results + resultStatus = TaskStatus.canceled; + } else { + await requestCompleter.future; // wait for request to complete + } + } catch (e) { + resultStatus = TaskStatus.failed; + setTaskError(e); + } + return (task, resultStatus); +} + +/// Do the multipart upload and return the TaskStatus +/// +/// Sends updates via the [sendPort] and can be commanded to cancel via +/// the [messagesToIsolate] queue +Future<(Task, TaskStatus)> multipartUpload( + UploadTask task, + SendPort sendPort, +) async { + // field portion of the multipart, all in one string + // multiple values should be encoded as '"value1", "value2", ...' + final multiValueRegEx = RegExp(r'^(?:"[^"]+"\s*,\s*)+"[^"]+"$'); + var fieldsString = ''; + for (final entry in task.fields.entries) { + if (multiValueRegEx.hasMatch(entry.value)) { + // extract multiple values from entry.value + for (final match in RegExp(r'"([^"]+)"').allMatches(entry.value)) { + fieldsString += fieldEntry(entry.key, match.group(1) ?? 'error'); + } + } else { + fieldsString += fieldEntry( + entry.key, + entry.value, + ); // single value for key + } + } + // File portion of the multi-part + // Assumes list of files. If only one file, that becomes a list of length one. + // For each file, determine contentDispositionString, contentTypeString + // and file length, so that we can calculate total size of upload + const separator = '$lineFeed--$boundary$lineFeed'; // between files + const terminator = '$lineFeed--$boundary--$lineFeed'; // after last file + final filesData = (task is MultiUploadTask) + ? await task + .extractFilesData() // MultiUpload case + : [ + (task.fileField, await task.filePath(), task.mimeType), + ]; // one file Upload case + final contentDispositionStrings = []; + final contentTypeStrings = []; + final fileLengths = []; + for (final (fileField, path, mimeType) in filesData) { + final file = File(path); + if (!await file.exists()) { + logError(task, 'File to upload does not exist: $path'); + taskException = TaskFileSystemException( + 'File to upload does not exist: $path', + ); + return (task, TaskStatus.failed); + } + final resolvedMimeType = mimeType.isEmpty ? lookupMimeType(path) : mimeType; + final derivedFilename = p.basename(file.path); + if (filesData.length == 1) { + // only for single file uploads do we set the task's filename property + if (task case UriUploadTask(fileUri: final fileUri) + when fileUri != null) { + task = task.copyWith(filename: fileUri.pathSegments.last); + } else { + task = task.copyWith(filename: derivedFilename); + } + } + contentDispositionStrings.add( + 'Content-Disposition: form-data; name="${browserEncode(fileField)}"; ' + 'filename="${browserEncode(derivedFilename)}"$lineFeed', + ); + contentTypeStrings.add('Content-Type: $resolvedMimeType$lineFeed$lineFeed'); + fileLengths.add(file.lengthSync()); + } + final fileDataLength = + contentDispositionStrings.fold( + 0, + (sum, string) => sum + lengthInBytes(string), + ) + + contentTypeStrings.fold(0, (sum, string) => sum + string.length) + + fileLengths.fold(0, (sum, length) => sum + length) + + separator.length * contentDispositionStrings.length + + 2; + final contentLength = + lengthInBytes(fieldsString) + + '--$boundary$lineFeed'.length + + fileDataLength; + var resultStatus = TaskStatus.failed; + try { + // setup the connection + final client = DesktopDownloader.httpClientForUrl(task.url); + final request = http.StreamedRequest( + task.httpRequestMethod, + Uri.parse(task.url), + ); + request.maxRedirects = defaultMaxRedirects; + request.contentLength = contentLength; + request.headers.addAll(task.headers); + request.headers.addAll({ + 'Content-Type': 'multipart/form-data; boundary=$boundary', + 'Accept-Charset': 'UTF-8', + 'Connection': 'Keep-Alive', + 'Cache-Control': 'no-cache', + }); + request.persistentConnection = false; + // initiate the request and handle completion async + final requestCompleter = Completer(); + var transferBytesResult = TaskStatus.failed; + client.send(request).then((response) async { + // request completed, so send status update and finish + resultStatus = + transferBytesResult == TaskStatus.complete && + !okResponses.contains(response.statusCode) + ? TaskStatus.failed + : transferBytesResult; + responseBody = await responseContent(response); + responseHeaders = response.headers; + responseStatusCode = response.statusCode; + taskException ??= TaskHttpException( + responseBody?.isNotEmpty == true + ? responseBody! + : response.reasonPhrase ?? 'Invalid HTTP response', + response.statusCode, + ); + if (response.statusCode == 404) { + resultStatus = TaskStatus.notFound; + } + requestCompleter.complete(); + }); + + // write fields + request.sink.add(utf8.encode('$fieldsString--$boundary$lineFeed')); + // write each file + for (final (index, fileData) in filesData.indexed) { + request.sink.add(utf8.encode(contentDispositionStrings[index])); + request.sink.add(utf8.encode(contentTypeStrings[index])); + // send the bytes to the request sink + final inStream = File(fileData.$2).openRead(); + transferBytesResult = await transferBytes( + inStream, + request.sink, + contentLength, + task, + sendPort, + ); + if (transferBytesResult != TaskStatus.complete || isCanceled) { + break; + } else { + request.sink.add( + utf8.encode(fileData == filesData.last ? terminator : separator), + ); + } + } + request.sink.close(); // triggers request completion, handled above + await requestCompleter.future; // wait for request to complete + } catch (e) { + resultStatus = TaskStatus.failed; + setTaskError(e); + } + if (isCanceled) { + // cancellation overrides other results + resultStatus = TaskStatus.canceled; + } + return (task, resultStatus); +} diff --git a/third_party/background_downloader/lib/src/exceptions.dart b/third_party/background_downloader/lib/src/exceptions.dart new file mode 100644 index 0000000..c9fd3e9 --- /dev/null +++ b/third_party/background_downloader/lib/src/exceptions.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +const _exceptions = { + 'TaskException': TaskException.new, + 'TaskFileSystemException': TaskFileSystemException.new, + 'TaskUrlException': TaskUrlException.new, + 'TaskConnectionException': TaskConnectionException.new, + 'TaskResumeException': TaskResumeException.new, + 'TaskHttpException': TaskHttpException.new, +}; + +/// Contains Exception information associated with a failed [Task] +/// +/// The [exceptionType] categorizes and describes the exception +/// The [description] is typically taken from the platform-generated +/// exception message, or from the plugin. The localization is undefined +/// For the [TaskHttpException], the [httpResponseCode] is only valid if >0 +/// and may offer details about the nature of the error +base class TaskException(final String description) implements Exception { + String get exceptionType => 'TaskException'; + + /// Create object from [json] + factory TaskException.fromJson(Map json) { + final typeString = json['type'] as String? ?? 'TaskException'; + final exceptionType = _exceptions[typeString]; + final description = json['description'] as String? ?? ''; + if (exceptionType != null) { + final httpResponseCode = + (json['httpResponseCode'] as num?)?.toInt() ?? -1; + return typeString != 'TaskHttpException' + ? exceptionType(description) + : exceptionType(description, httpResponseCode); + } + return TaskException('Unknown'); + } + + /// Create object from String description of the type, and parameters + factory TaskException.fromTypeString( + String typeString, + String description, [ + int httpResponseCode = -1, + ]) { + final exceptionType = _exceptions[typeString] ?? TaskException.new; + return typeString != 'TaskHttpException' + ? exceptionType(description) + : exceptionType(description, httpResponseCode); + } + + /// Return JSON Map representing object + Map toJson() => { + 'type': exceptionType, + 'description': description, + }; + + /// Return JSON String representing object + String toJsonString() => jsonEncode(toJson()); + + @override + String toString() => '$exceptionType: $description'; +} + +/// Exception related to the filesystem, e.g. insufficient space +/// or file not found +final class TaskFileSystemException(super.description) extends TaskException { + @override + String get exceptionType => 'TaskFileSystemException'; +} + +/// Exception related to the url, eg malformed +final class TaskUrlException(super.description) extends TaskException { + @override + String get exceptionType => 'TaskUrlException'; +} + +/// Exception related to the connection, e.g. socket exception +/// or request timeout +final class TaskConnectionException(super.description) extends TaskException { + @override + String get exceptionType => 'TaskConnectionException'; +} + +/// Exception related to an attempt to resume a task, e.g. +/// the temp filename no longer exists, or eTag has changed +final class TaskResumeException(super.description) extends TaskException { + @override + String get exceptionType => 'TaskResumeException'; +} + +/// Exception related to the HTTP response, e.g. a 403 +/// response code +final class TaskHttpException(super.description, final int httpResponseCode) + extends TaskException { + @override + String get exceptionType => 'TaskHttpException'; + + @override + Map toJson() => { + ...super.toJson(), + 'httpResponseCode': httpResponseCode, + }; + + @override + String toString() => + '$exceptionType, response code $httpResponseCode: $description'; +} diff --git a/third_party/background_downloader/lib/src/file_downloader.dart b/third_party/background_downloader/lib/src/file_downloader.dart new file mode 100644 index 0000000..68653d4 --- /dev/null +++ b/third_party/background_downloader/lib/src/file_downloader.dart @@ -0,0 +1,1556 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import 'base_downloader.dart'; +import 'database.dart'; +import 'localstore/localstore.dart'; +import 'models.dart'; +import 'permissions.dart'; +import 'persistent_storage.dart'; +import 'queue/task_queue.dart'; +import 'task.dart'; +import 'transfers.dart'; +import 'uri/uri_utils.dart'; +import 'web_downloader.dart' + if (dart.library.io) 'desktop/desktop_downloader.dart'; + +/// Provides access to all functions of the plugin in a single place. +interface class FileDownloader { + static FileDownloader? _singleton; + static final Map _scopedInstances = {}; + + /// If no group is specified the default group name will be used + static const defaultGroup = 'default'; + + /// Special group name for tasks that download a chunk, as part of a + /// [ParallelDownloadTask] + static String get chunkGroup => BaseDownloader.chunkGroup; + + /// The namespace for this scoped downloader instance, or null for the root default instance + final String? namespace; + + /// Whether this downloader instance is scoped to a specific namespace + bool get hasNamespace => namespace != null && namespace!.isNotEmpty; + + /// Database where tracked tasks are stored. + /// + /// Activate tracking by calling [trackTasks], and access the records in the + /// database via this [database] object. + late final Database database; + + /// Transfer management and reactive collections service + late final Transfers transfers; + + /// Permissions authorization interface + /// + /// Use [Permissions.status] to get the authorization status for a permission + /// Use [Permissions.request] to request a permission + /// Use [Permissions.shouldShowRationale] to determine if an educational + /// rationale for this permission should be shown + Permissions get permissions => _downloader.permissionsService; + + /// Platform-specific implementation of the downloader itself + late final BaseDownloader _downloader; + + /// Accesses utilities for working with URIs + UriUtils? _uri; + + /// Do not use: for testing only + @visibleForTesting + BaseDownloader get downloaderForTesting => _downloader; + + factory FileDownloader({ + PersistentStorage? persistentStorage, + String? namespace, + }) { + if (namespace != null && namespace.isNotEmpty) { + return FileDownloader.scoped( + namespace, + persistentStorage: persistentStorage, + ); + } + assert( + _singleton == null || persistentStorage == null, + 'You can only supply a persistentStorage on the very first call to ' + 'FileDownloader()', + ); + _singleton ??= FileDownloader._internal( + persistentStorage ?? LocalStorePersistentStorage(), + null, + ); + return _singleton!; + } + + /// Returns a cached, scoped [FileDownloader] instance for [namespace]. + /// + /// Scoped instances share the underlying downloader engine and database, + /// but isolate group naming, callback dispatch, task queries, and cancellation + /// to the given namespace. + factory FileDownloader.scoped( + String namespace, { + PersistentStorage? persistentStorage, + }) { + if (namespace.isEmpty) { + return FileDownloader(persistentStorage: persistentStorage); + } + return _scopedInstances.putIfAbsent(namespace, () { + final root = FileDownloader(persistentStorage: persistentStorage); + return FileDownloader._scoped(root.database, root._downloader, namespace); + }); + } + + FileDownloader._internal( + PersistentStorage persistentStorage, + this.namespace, + ) { + database = Database(persistentStorage); + _downloader = BaseDownloader.instance(persistentStorage, database); + transfers = Transfers(this, _downloader); + } + + FileDownloader._scoped(this.database, this._downloader, this.namespace) { + transfers = Transfers(this, _downloader); + } + + /// True when initialization is complete and downloader ready for use + Future get ready => _downloader.ready; + + /// True when the device currently has an active network connection + bool get isConnected => _downloader.isConnected; + + @visibleForTesting + set isConnected(bool value) => _downloader.isConnected = value; + + /// True when the active network connection is Wi-Fi + bool get isWiFi => _downloader.isWiFi; + + @visibleForTesting + set isWiFi(bool value) => _downloader.isWiFi = value; + + /// Stream of [TaskUpdate] updates for downloads that do + /// not have a registered callback. + /// + /// **Important**: This is a single-subscription stream. It can only be listened to + /// once. If you are developing a package or plugin, you should **not** listen + /// to this stream, as doing so will prevent the main application from listening to it. + /// Instead, use [registerCallbacks] with a custom group to monitor tasks specific + /// to your package. + Stream get updates => _downloader.updates.stream; + + /// Accesses utilities for working with URIs. URIs make working with file pickers and + /// shared storage easier, as they abstract permissions and are coherent + /// across platforms. + UriUtils get uri { + _uri ??= UriUtils.withDownloader(_downloader); + return _uri!; + } + + /// Configures the downloader + /// + /// Configuration is either a single configItem or a list of configItems. + /// Each configItem is a (String, dynamic) where the String is the config + /// type and 'dynamic' can be any appropriate parameter, including another Record. + /// [globalConfig] is routed to every platform, whereas the platform specific + /// ones only get routed to that platform, after the global configs have + /// completed. + /// If a config type appears more than once, they will all be executed in order, + /// with [globalConfig] executed before the platform-specific config. + /// + /// Returns a list of (String, String) which is the config type and a response + /// which is empty if OK, 'not implemented' if the item could not be recognized and + /// processed, or may contain other error/warning information + /// + /// Please see [CONFIG.md](https://github.com/781flyingdutchman/background_downloader/blob/main/CONFIG.md) + /// for more information + Future> configure({ + dynamic globalConfig, + dynamic androidConfig, + dynamic iOSConfig, + dynamic desktopConfig, + }) => _downloader.configure( + globalConfig: globalConfig, + androidConfig: androidConfig, + iOSConfig: iOSConfig, + desktopConfig: desktopConfig, + ); + + /// Converts a group name to its namespaced equivalent. + String namespacedGroup(String? group) { + final cleanGroup = (group == null || group.isEmpty) + ? FileDownloader.defaultGroup + : group; + if (!hasNamespace) return cleanGroup; + if (cleanGroup == FileDownloader.defaultGroup) return '$namespace.default'; + if (cleanGroup.startsWith('$namespace.')) return cleanGroup; + return '$namespace.$cleanGroup'; + } + + /// Converts a namespaced group name back to the original un-namespaced group name. + String originalGroup(String group) { + if (!hasNamespace) return group; + if (group == '$namespace.default') return FileDownloader.defaultGroup; + if (group.startsWith('$namespace.')) { + return group.substring(namespace!.length + 1); + } + return group; + } + + /// Returns a copy of [task] with its group scoped to this namespace. + Task withNamespacedGroup(Task task) { + if (!hasNamespace) return task; + return task.copyWith(group: namespacedGroup(task.group)); + } + + /// Returns a copy of [task] with the namespace prefix removed from its group. + Task withoutNamespacedGroup(Task task) { + if (!hasNamespace) return task; + return task.copyWith(group: originalGroup(task.group)); + } + + /// Register status or progress callbacks to monitor download progress, and + /// [TaskNotificationTapCallback] to respond to user tapping a notification. + /// + /// Status callbacks are called only when the state changes, while + /// progress callbacks are called to inform of intermediate progress. + /// + /// Note that callbacks will be called based on a task's [updates] + /// property, which defaults to status change callbacks only. To also get + /// progress updates make sure to register a [TaskProgressCallback] and + /// set the task's [updates] property to [Updates.progress] or + /// [Updates.statusAndProgress]. + /// + /// For notification callbacks, make sure your AndroidManifest includes + /// android:launchMode="singleTask" to ensure proper behavior when a + /// notification is tapped. + /// + /// Different callbacks can be set for different groups, and the group + /// can be passed on with the [Task] to ensure the + /// appropriate callbacks are called for that group. + /// For the `taskNotificationTapCallback` callback, the `defaultGroup` callback + /// is used when calling 'convenience' functions like `FileDownloader().download` + /// + /// **Important for package developers**: Tasks belonging to a group that has + /// registered callbacks will **not** emit updates to the [updates] stream. + /// This makes [registerCallbacks] the preferred way for packages to monitor + /// their own tasks, as it avoids interfering with the main application's + /// single-subscription listener on the [updates] stream. + static final _registeredStatusCallbacks = {}; + static final _registeredProgressCallbacks = {}; + static final _registeredNotificationTapCallbacks = + {}; + + /// + /// The call returns the [FileDownloader] to make chaining easier + FileDownloader registerCallbacks({ + String group = defaultGroup, + TaskStatusCallback? taskStatusCallback, + TaskProgressCallback? taskProgressCallback, + TaskNotificationTapCallback? taskNotificationTapCallback, + }) { + assert( + taskStatusCallback != null || + taskProgressCallback != null || + taskNotificationTapCallback != null, + 'Must provide at least one callback', + ); + final nGroup = namespacedGroup(group); + if (taskStatusCallback != null) { + if (hasNamespace) { + _registeredStatusCallbacks[nGroup] = taskStatusCallback; + _downloader.groupStatusCallbacks[nGroup] = (update) { + taskStatusCallback( + TaskStatusUpdate( + withoutNamespacedGroup(update.task), + update.status, + update.exception, + update.responseBody, + update.responseHeaders, + update.responseStatusCode, + update.mimeType, + update.charSet, + ), + ); + }; + } else { + _downloader.groupStatusCallbacks[nGroup] = taskStatusCallback; + } + } + if (taskProgressCallback != null) { + if (hasNamespace) { + _registeredProgressCallbacks[nGroup] = taskProgressCallback; + _downloader.groupProgressCallbacks[nGroup] = (update) { + taskProgressCallback( + TaskProgressUpdate( + withoutNamespacedGroup(update.task), + update.progress, + update.expectedFileSize, + update.networkSpeed, + update.timeRemaining, + ), + ); + }; + } else { + _downloader.groupProgressCallbacks[nGroup] = taskProgressCallback; + } + } + if (taskNotificationTapCallback != null) { + if (hasNamespace) { + _registeredNotificationTapCallbacks[nGroup] = + taskNotificationTapCallback; + _downloader.groupNotificationTapCallbacks[nGroup] = + (task, notificationType) { + taskNotificationTapCallback( + withoutNamespacedGroup(task), + notificationType, + ); + }; + } else { + _downloader.groupNotificationTapCallbacks[nGroup] = + taskNotificationTapCallback; + } + } + return this; + } + + /// Unregister a previously registered [TaskStatusCallback], [TaskProgressCallback] + /// or [TaskNotificationTapCallback]. + /// + /// [group] defaults to the [FileDownloader.defaultGroup] + /// If [callback] is null, all callbacks for the [group] are unregistered + FileDownloader unregisterCallbacks({ + String group = defaultGroup, + Function? callback, + }) { + final nGroup = namespacedGroup(group); + if (callback != null) { + // remove specific callback + if (_downloader.groupStatusCallbacks[nGroup] == callback || + _registeredStatusCallbacks[nGroup] == callback) { + _downloader.groupStatusCallbacks.remove(nGroup); + _registeredStatusCallbacks.remove(nGroup); + } + if (_downloader.groupProgressCallbacks[nGroup] == callback || + _registeredProgressCallbacks[nGroup] == callback) { + _downloader.groupProgressCallbacks.remove(nGroup); + _registeredProgressCallbacks.remove(nGroup); + } + if (_downloader.groupNotificationTapCallbacks[nGroup] == callback || + _registeredNotificationTapCallbacks[nGroup] == callback) { + _downloader.groupNotificationTapCallbacks.remove(nGroup); + _registeredNotificationTapCallbacks.remove(nGroup); + } + } else { + // remove all callbacks related to group + _downloader.groupStatusCallbacks.remove(nGroup); + _downloader.groupProgressCallbacks.remove(nGroup); + _downloader.groupNotificationTapCallbacks.remove(nGroup); + _registeredStatusCallbacks.remove(nGroup); + _registeredProgressCallbacks.remove(nGroup); + _registeredNotificationTapCallbacks.remove(nGroup); + } + return this; + } + + /// Adds the [taskQueue] to this downloader + /// + /// Every [TaskQueue] will receive [TaskQueue.taskFinished] for + /// every task that has reached a final state + void addTaskQueue(TaskQueue taskQueue) => + _downloader.taskQueues.add(taskQueue); + + /// Removes [taskQueue] and return true if successful + bool removeTaskQueue(TaskQueue taskQueue) => + _downloader.taskQueues.remove(taskQueue); + + /// List of connected [TaskQueue]s + List get taskQueues => _downloader.taskQueues; + + /// Enqueue a new [Task] + /// + /// Returns true if successfully enqueued. A new task will also generate + /// a [TaskStatus.enqueued] update to the registered callback, + /// if requested by its [updates] property + /// + /// Use [enqueue] instead of the convenience functions (like + /// [download] and [upload]) if: + /// - your download/upload is likely to take long and may require + /// running in the background + /// - you want to monitor tasks centrally, via a listener + /// - you want more detailed progress information + /// (e.g. file size, network speed, time remaining) + Future enqueue(Task task) => + _downloader.enqueue(withNamespacedGroup(task)); + + /// Enqueues a list of tasks and returns a list of booleans indicating whether + /// each task was successfully enqueued + /// + /// See [enqueue] for details + Future> enqueueAll(Iterable tasks) => + _downloader.enqueueAll(tasks.map(withNamespacedGroup)); + + /// Download a file and return the final [TaskStatusUpdate] + /// + /// Different from [enqueue], this method returns a [Future] that completes + /// when the file has been downloaded, or an error has occurred. + /// While it uses the same download mechanism as [enqueue], + /// and will execute the download also when + /// the app moves to the background, it is meant for downloads that are + /// awaited while the app is in the foreground. + /// + /// Optional callbacks for status and progress updates may be + /// added. These function only take a [TaskStatus] or [double] argument as + /// the task they refer to is expected to be captured in the closure for + /// this call. + /// For example `Downloader.download(task, onStatus: (status) =>` + /// `print('Status for ${task.taskId} is $status);` + /// + /// An optional callback [onElapsedTime] will be called at regular intervals + /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a + /// single argument that is the elapsed time since the call to [download]. + /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') + /// or to cancel the task if it does not complete within a desired time. + /// For performance reasons the [elapsedTimeInterval] should not be set to + /// a value less than one second. + /// The [onElapsedTime] callback should not be used to indicate progress. For + /// that, use the [onProgress] callback. + /// + /// Use [enqueue] instead of [download] if: + /// - your download/upload is likely to take long and may require + /// running in the background + /// - you want to monitor tasks centrally, via a listener + /// - you want more detailed progress information + /// (e.g. file size, network speed, time remaining) + Future download( + DownloadTask task, { + void Function(TaskStatus)? onStatus, + void Function(double)? onProgress, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) => _downloader.enqueueAndAwait( + withNamespacedGroup(task) as DownloadTask, + onStatus: onStatus, + onProgress: onProgress, + onElapsedTime: onElapsedTime, + elapsedTimeInterval: elapsedTimeInterval, + ); + + /// Upload a file and return the final [TaskStatusUpdate] + /// + /// Different from [enqueue], this method returns a [Future] that completes + /// when the file has been uploaded, or an error has occurred. + /// While it uses the same upload mechanism as [enqueue], + /// and will execute the upload also when + /// the app moves to the background, it is meant for uploads that are + /// awaited while the app is in the foreground. + /// + /// Optional callbacks for status and progress updates may be + /// added. These function only take a [TaskStatus] or [double] argument as + /// the task they refer to is expected to be captured in the closure for + /// this call. + /// For example `Downloader.upload(task, onStatus: (status) =>` + /// `print('Status for ${task.taskId} is $status);` + /// + /// An optional callback [onElapsedTime] will be called at regular intervals + /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a + /// single argument that is the elapsed time since the call to [upload]. + /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') + /// or to cancel the task if it does not complete within a desired time. + /// For performance reasons the [elapsedTimeInterval] should not be set to + /// a value less than one second. + /// The [onElapsedTime] callback should not be used to indicate progress. For + /// that, use the [onProgress] callback. + /// + /// Note that the task's [group] is ignored and will be replaced with an + /// internal group name 'await' to track status + /// + /// Use [enqueue] instead of [upload] if: + /// - your download/upload is likely to take long and may require + /// running in the background + /// - you want to monitor tasks centrally, via a listener + /// - you want more detailed progress information + /// (e.g. file size, network speed, time remaining) + Future upload( + UploadTask task, { + void Function(TaskStatus)? onStatus, + void Function(double)? onProgress, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) => _downloader.enqueueAndAwait( + withNamespacedGroup(task) as UploadTask, + onStatus: onStatus, + onProgress: onProgress, + onElapsedTime: onElapsedTime, + elapsedTimeInterval: elapsedTimeInterval, + ); + + /// Transmit data in the [DataTask] and receive the response + /// + /// Different from [enqueue], this method returns a [Future] that completes + /// when the [DataTask] has completed, or an error has occurred. + /// While it uses the same mechanism as [enqueue], + /// and will execute the task also when + /// the app moves to the background, it is meant for data tasks that are + /// awaited while the app is in the foreground. + /// + /// [onStatus] is an optional callback for status updates + /// + /// An optional callback [onElapsedTime] will be called at regular intervals + /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a + /// single argument that is the elapsed time since the call to [transmit]. + /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') + /// For performance reasons the [elapsedTimeInterval] should not be set to + /// a value less than one second. + Future transmit( + DataTask task, { + void Function(TaskStatus)? onStatus, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) => _downloader.enqueueAndAwait( + withNamespacedGroup(task) as DataTask, + onStatus: onStatus, + onElapsedTime: onElapsedTime, + elapsedTimeInterval: elapsedTimeInterval, + ); + + /// Enqueues a list of files to download and returns when all downloads + /// have finished (successfully or otherwise). The returned value is a + /// [Batch] object that contains the original [tasks], the + /// [Batch.results] and convenience getters to filter successful and failed results. + /// + /// If an optional [batchProgressCallback] function is provided, it will be + /// called upon completion (successfully or otherwise) of each task in the + /// batch, with two parameters: the number of succeeded and the number of + /// failed tasks. The callback can be used, for instance, to show a progress + /// indicator for the batch, where + /// double percent_complete = (succeeded + failed) / tasks.length + /// + /// To also monitor status and/or progress for each task in the batch, provide + /// a [taskStatusCallback] and/or [taskProgressCallback], which will be used + /// for each task in the batch. + /// + /// An optional callback [onElapsedTime] will be called at regular intervals + /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a + /// single argument that is the elapsed time since the call to [downloadBatch]. + /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') + /// or to cancel the task if it does not complete within a desired time. + /// For performance reasons the [elapsedTimeInterval] should not be set to + /// a value less than one second. + /// The [onElapsedTime] callback should not be used to indicate progress. + /// + /// [tasks] cannot be an empty list + Future downloadBatch( + List tasks, { + BatchProgressCallback? batchProgressCallback, + TaskStatusCallback? taskStatusCallback, + TaskProgressCallback? taskProgressCallback, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) => _downloader.enqueueAndAwaitBatch( + tasks.map((t) => withNamespacedGroup(t) as DownloadTask).toList(), + batchProgressCallback: batchProgressCallback, + taskStatusCallback: taskStatusCallback != null + ? (update) => taskStatusCallback( + TaskStatusUpdate( + withoutNamespacedGroup(update.task), + update.status, + update.exception, + update.responseBody, + update.responseHeaders, + update.responseStatusCode, + update.mimeType, + update.charSet, + ), + ) + : null, + taskProgressCallback: taskProgressCallback != null + ? (update) => taskProgressCallback( + TaskProgressUpdate( + withoutNamespacedGroup(update.task), + update.progress, + update.expectedFileSize, + update.networkSpeed, + update.timeRemaining, + ), + ) + : null, + onElapsedTime: onElapsedTime, + elapsedTimeInterval: elapsedTimeInterval, + ); + + /// Enqueues a list of files to upload and returns when all uploads + /// have finished (successfully or otherwise). The returned value is a + /// [Batch] object that contains the original [tasks], the + /// [Batch.results] and convenience getters to filter successful and failed results. + /// + /// If an optional [batchProgressCallback] function is provided, it will be + /// called upon completion (successfully or otherwise) of each task in the + /// batch, with two parameters: the number of succeeded and the number of + /// failed tasks. The callback can be used, for instance, to show a progress + /// indicator for the batch, where + /// double percent_complete = (succeeded + failed) / tasks.length + /// + /// To also monitor status and/or progress for each task in the batch, provide + /// a [taskStatusCallback] and/or [taskProgressCallback], which will be used + /// for each task in the batch. + /// + /// An optional callback [onElapsedTime] will be called at regular intervals + /// (defined by [elapsedTimeInterval], which defaults to 5 seconds) with a + /// single argument that is the elapsed time since the call to [uploadBatch]. + /// This can be used to trigger UI warnings (e.g. 'this is taking rather long') + /// or to cancel the task if it does not complete within a desired time. + /// For performance reasons the [elapsedTimeInterval] should not be set to + /// a value less than one second. + /// The [onElapsedTime] callback should not be used to indicate progress. + /// + /// [tasks] cannot be an empty list + Future uploadBatch( + List tasks, { + BatchProgressCallback? batchProgressCallback, + TaskStatusCallback? taskStatusCallback, + TaskProgressCallback? taskProgressCallback, + void Function(Duration)? onElapsedTime, + Duration? elapsedTimeInterval, + }) => _downloader.enqueueAndAwaitBatch( + tasks.map((t) => withNamespacedGroup(t) as UploadTask).toList(), + batchProgressCallback: batchProgressCallback, + taskStatusCallback: taskStatusCallback != null + ? (update) => taskStatusCallback( + TaskStatusUpdate( + withoutNamespacedGroup(update.task), + update.status, + update.exception, + update.responseBody, + update.responseHeaders, + update.responseStatusCode, + update.mimeType, + update.charSet, + ), + ) + : null, + taskProgressCallback: taskProgressCallback != null + ? (update) => taskProgressCallback( + TaskProgressUpdate( + withoutNamespacedGroup(update.task), + update.progress, + update.expectedFileSize, + update.networkSpeed, + update.timeRemaining, + ), + ) + : null, + onElapsedTime: onElapsedTime, + elapsedTimeInterval: elapsedTimeInterval, + ); + + /// Resets the downloader by cancelling all ongoing tasks within + /// the provided [group] (or within this namespace). + /// + /// Returns the number of tasks cancelled. Every canceled task will emit a + /// [TaskStatus.canceled] update to the registered callback, if requested. + /// + /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] + /// is used, which is the group used when you [enqueue] a task. + Future reset({String group = defaultGroup}) async { + if (hasNamespace) { + return _downloader.reset(namespacedGroup(group)); + } + return _downloader.reset(group); + } + + /// Returns a list of taskIds of all tasks currently active in this [group] + /// + /// Active means enqueued or running, and if [includeTasksWaitingToRetry] is + /// true also tasks that are waiting to be retried. + /// + /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] + /// is used, which is the group used when you [enqueue] a task. + /// To get all tasks regardless of group, set [allGroups] to true as the + /// only parameter. + Future> allTaskIds({ + String group = defaultGroup, + bool includeTasksWaitingToRetry = true, + allGroups = false, + }) async => (await allTasks( + group: group, + includeTasksWaitingToRetry: includeTasksWaitingToRetry, + allGroups: allGroups, + )).map((task) => task.taskId).toList(); + + /// Returns a list of all tasks currently active in this [group] + /// + /// Active means enqueued or running, and if [includeTasksWaitingToRetry] is + /// true also tasks that are waiting to be retried. + /// + /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] + /// is used, which is the group used when you [enqueue] a task. + /// To get all tasks regardless of group, set [allGroups] to true as the + /// only parameter. + Future> allTasks({ + String group = defaultGroup, + bool includeTasksWaitingToRetry = true, + bool allGroups = false, + }) async { + if (hasNamespace) { + if (allGroups) { + final rawTasks = await _downloader.allTasks( + defaultGroup, + includeTasksWaitingToRetry, + true, + ); + return rawTasks + .where((t) => t.group.startsWith('$namespace.')) + .map(withoutNamespacedGroup) + .toList(); + } + final rawTasks = await _downloader.allTasks( + namespacedGroup(group), + includeTasksWaitingToRetry, + false, + ); + return rawTasks.map(withoutNamespacedGroup).toList(); + } + return _downloader.allTasks(group, includeTasksWaitingToRetry, allGroups); + } + + /// Returns true if tasks in this [group] are finished + /// + /// Finished means "not active", i.e. no tasks are enqueued or running, + /// and if [includeTasksWaitingToRetry] is true (the default), no tasks are + /// waiting to be retried. + /// Finished does not mean that all tasks completed successfully. + /// + /// This method acts on a [group] of tasks. If omitted, the [defaultGroup] + /// is used, which is the group used when you [enqueue] a task. + /// + /// If an [ignoreTaskId] is provided, it will be excluded from the test. This + /// allows you to test for [tasksFinished] within the status update callback + /// for a task that just finished. In that situation, that task may still + /// be returned by the platform as 'active', but you already know it is not. + /// Calling [tasksFinished] while passing that just-finished task will ensure + /// a proper test in that situation. + Future tasksFinished({ + String group = defaultGroup, + bool includeTasksWaitingToRetry = true, + String? ignoreTaskId, + }) async { + final tasksInProgress = await allTasks( + group: group, + includeTasksWaitingToRetry: includeTasksWaitingToRetry, + ); + if (ignoreTaskId != null) { + tasksInProgress.removeWhere((task) => task.taskId == ignoreTaskId); + } + return tasksInProgress.isEmpty; + } + + /// Cancel all tasks matching the taskIds in the list + /// + /// Every canceled task wil emit a [TaskStatus.canceled] update to + /// the registered callback, if requested + Future cancelTasksWithIds(Iterable taskIds) => + _downloader.cancelTasksWithIds(taskIds); + + /// Cancel this task + /// + /// The task will emit a [TaskStatus.canceled] update to + /// the registered callback, if requested + Future cancelTaskWithId(String taskId) => cancelTasksWithIds([taskId]); + + /// Cancel this task + /// + /// The task will emit a [TaskStatus.canceled] update to + /// the registered callback, if requested + Future cancel(Task task) => cancelTasksWithIds([task.taskId]); + + /// Cancels all tasks, or those in [tasks], or all tasks in group [group] + /// + /// Returns true if all cancellations were successful + Future cancelAll({Iterable? tasks, String? group}) async { + if (hasNamespace) { + if (tasks != null) { + return _downloader.cancelAll(tasks: tasks.map(withNamespacedGroup)); + } + final nTasks = await allTasks( + group: group ?? defaultGroup, + allGroups: group == null, + ); + return _downloader.cancelAll(tasks: nTasks.map(withNamespacedGroup)); + } + return _downloader.cancelAll(tasks: tasks, group: group); + } + + /// Return [Task] for the given [taskId], or null + /// if not found. + /// + /// Only running tasks are guaranteed to be returned, but returning a task + /// does not guarantee that the task is still running. To keep track of + /// the status of tasks, use a [TaskStatusCallback] + Future taskForId(String taskId) => _downloader.taskForId(taskId); + + /// Convenience start method for using the database. Must be called AFTER + /// registering update callbacks or listener. + /// + /// Calls in order: + /// [trackTasks] to start database tracking, with [markDownloadedComplete] to + /// ensure fully downloaded tasks are marked as complete in the database + /// [resumeFromBackground] to fetch status and progress updates that may have + /// happened while the app was suspended + /// [rescheduleKilledTasks] (after a 5 second delay) to ensure tasks that were + /// killed by the user are rescheduled + /// + /// [doTrackTasks] and [doRescheduleKilledTasks] can be set to false to skip + /// that step. [resumeFromBackground] is always called. + /// + /// If [autoCleanDatabase] is true, [Database.cleanUp] is called after + /// initialization to purge old or excess records and prevent the database + /// from growing indefinitely. While [autoCleanDatabase] defaults to `false` + /// to maintain backwards compatibility for existing code, calling + /// `FileDownloader().start(autoCleanDatabase: true)` is typically recommended. + Future start({ + bool doTrackTasks = true, + bool markDownloadedComplete = true, + bool doRescheduleKilledTasks = true, + bool autoCleanDatabase = false, + }) async { + if (doTrackTasks) { + await trackTasks(markDownloadedComplete: markDownloadedComplete); + await transfers.rehydrateFromDatabase(); + if (doRescheduleKilledTasks) { + Timer(const Duration(seconds: 5), () => rescheduleKilledTasks()); + } + } + await resumeFromBackground(); + if (autoCleanDatabase) { + database.cleanUp(); + } + } + + /// Activate tracking for tasks in this [group] + /// + /// All subsequent tasks in this group will be recorded in persistent storage. + /// Use the [FileDownloader.database] to get or remove [TaskRecord] objects, + /// which contain a [Task], its [TaskStatus] and a [double] for progress. + /// + /// If [markDownloadedComplete] is true (default) then all tasks in the + /// database that are marked as not yet [TaskStatus.complete] will be set to + /// [TaskStatus.complete] if the target file for that task exists. + /// They will also emit [TaskStatus.complete] and [progressComplete] to + /// their registered listener or callback. + /// This is a convenient way to capture downloads that have completed while + /// the app was suspended: on app startup, immediately register your + /// listener or callbacks, and call [trackTasks] for each group. + /// + /// Returns the [FileDownloader] for easy chaining + Future trackTasksInGroup( + String group, { + bool markDownloadedComplete = true, + }) async { + await _downloader.trackTasks( + namespacedGroup(group), + markDownloadedComplete, + ); + return this; + } + + /// Activate tracking for all tasks + /// + /// All subsequent tasks will be recorded in persistent storage. + /// Use the [FileDownloader.database] to get or remove [TaskRecord] objects, + /// which contain a [Task], its [TaskStatus] and a [double] for progress. + /// + /// If [markDownloadedComplete] is true (default) then all tasks in the + /// database that are marked as not yet [TaskStatus.complete] will be set to + /// [TaskStatus.complete] if the target file for that task exists. + /// They will also emit [TaskStatus.complete] and [progressComplete] to + /// their registered listener or callback. + /// This is a convenient way to capture downloads that have completed while + /// the app was suspended: on app startup, immediately register your + /// listener or callbacks, and call [trackTasks]. + /// + /// Returns the [FileDownloader] for easy chaining + Future trackTasks({ + bool markDownloadedComplete = true, + }) async { + if (hasNamespace) { + await _downloader.trackTasks( + namespacedGroup(null), + markDownloadedComplete, + ); + } else { + await _downloader.trackTasks(null, markDownloadedComplete); + } + return this; + } + + /// Wakes up the FileDownloader from possible background state, triggering + /// a stream of updates that may have been processed while in the background, + /// and have not yet reached the callbacks or listener + /// + /// Calling this method multiple times has no effect. + Future resumeFromBackground() => + _downloader.retrieveLocallyStoredData(); + + /// Reschedules tasks that are present in the database but missing from + /// the native task queue. Typically called on app start, 5s after establishing + /// the updates listener, calling [trackTasks] or [trackTasksInGroup] and + /// calling [resumeFromBackground]. + /// + /// This function retrieves all tasks from the database that are in enqueued + /// or running states and compares these tasks with the + /// list of tasks currently present in the native task queue, and all tasks + /// that are in waitingToRetry state that are not actually waiting to retry + /// in the downloader + /// + /// For each task found only in the database (or waitingToRetry yet not + /// actually waiting), the function: + /// 1. Deletes the corresponding record from the database. + /// 2. Enqueues the task back into the native task queue. + /// + /// Finally, the function returns two lists of Tasks in a record. The first + /// item is the list of successfully re-enqueued tasks, the second item + /// is the list of tasks that failed to enqueue. + /// + /// Throws assertion error if you are not currently tracking tasks, as that + /// makes this function a no-op that always returns empty lists. + Future<(List, List)> rescheduleKilledTasks() async { + assert( + _downloader.isTrackingTasks, + 'rescheduleKilledTasks should only be called if you are tracking tasks. ' + 'Did you call trackTasks or trackTasksInGroup?', + ); + await _downloader.flushDatabaseUpdates(); + final missingTasks = {}; + final databaseTasks = await database.allRecords(); + // find missing enqueued/running tasks + final enqueuedOrRunningDatabaseTasks = databaseTasks + .where( + (record) => const [ + TaskStatus.enqueued, + TaskStatus.running, + ].contains(record.status), + ) + .map((record) => record.task) + .toSet(); + final nativeTasks = Set.from(await allTasks(allGroups: true)); + missingTasks.addAll(enqueuedOrRunningDatabaseTasks.difference(nativeTasks)); + // find missing tasks waiting to retry + missingTasks.addAll( + databaseTasks + .where( + (record) => + record.status == TaskStatus.waitingToRetry && + !_downloader.tasksWaitingToRetry.contains(record.task), + ) + .map((record) => record.task), + ); + final successfullyEnqueued = []; + final failedToEnqueue = []; + var startTime = DateTime.now(); + for (final task in missingTasks) { + await database.deleteRecordWithId(task.taskId); + if (await enqueue(task)) { + successfullyEnqueued.add(task); + } else { + failedToEnqueue.add(task); + } + if (DateTime.now().difference(startTime).inMilliseconds > 10) { + await Future.delayed(const Duration(milliseconds: 50)); + startTime = DateTime.now(); + } + } + return (successfullyEnqueued, failedToEnqueue); + } + + /// Returns true if task can be resumed on pause + /// + /// This future only completes once the task is running and has received + /// information from the server to determine whether resume is possible, or + /// if the task fails and resume is possible + Future taskCanResume(Task task) => + _downloader.taskCanResume(withNamespacedGroup(task)); + + /// Pause the task + /// + /// Returns true if the pause was attempted successfully. Test the task's + /// status to see if it was executed successfully [TaskStatus.paused] or if + /// it failed after all [TaskStatus.failed] + /// + /// If the [Task.allowPause] field is set to false (default) or if this is + /// a POST request, this method returns false immediately. + Future pause(DownloadTask task) async { + if (task.allowPause && task.post == null) { + return _downloader.pause(withNamespacedGroup(task) as DownloadTask); + } + return false; + } + + /// Pauses all tasks, or those in [tasks], or all tasks in group [group] + /// + /// Returns list of tasks that were paused + Future> pauseAll({ + Iterable? tasks, + String? group, + }) { + final nGroup = group != null + ? namespacedGroup(group) + : (hasNamespace ? namespacedGroup(null) : null); + final nTasks = tasks?.map((t) => withNamespacedGroup(t) as DownloadTask); + for (final taskQueue in _downloader.taskQueues) { + taskQueue.pauseAll(tasks: nTasks, group: nGroup); + } + return _downloader.pauseAll(tasks: nTasks, group: nGroup); + } + + /// Resume the task + /// + /// If no resume data is available for this task, the call to [resume] + /// will return false and the task is not resumed. + /// If resume data is available, the call to [resume] will return true, + /// but this does not guarantee that resuming is actually possible, just that + /// the task is now enqueued for resume. + /// If the task is able to resume, it will, otherwise it will restart the + /// task from scratch, or fail. + Future resume(DownloadTask task) => + _downloader.resume(withNamespacedGroup(task) as DownloadTask); + + /// Resume all paused tasks, or those in [tasks], or paused tasks in + /// group [group] + /// + /// Calls to resume will be spaced out over time by [interval], defaults to 50ms + Future> resumeAll({ + Iterable? tasks, + String? group, + Duration interval = const Duration(milliseconds: 50), + }) async { + final nGroup = group != null + ? namespacedGroup(group) + : (hasNamespace ? namespacedGroup(null) : null); + final nTasks = tasks?.map((t) => withNamespacedGroup(t) as DownloadTask); + final results = []; + final tasksToResume = switch ((nTasks, nGroup)) { + (final Iterable tasks, null) => tasks, + (null, final String group) => + (await _downloader.getPausedTasks()).whereType().where( + (task) => task.group == group, + ), + (null, null) => + (await _downloader.getPausedTasks()).whereType(), + _ => throw AssertionError( + "Either 'tasks' or 'group' must be provided, or neither, but not both.", + ), + }; + for (final task in tasksToResume) { + if (await resume(task)) { + results.add(withoutNamespacedGroup(task)); + } + await Future.delayed(interval); + } + for (final taskQueue in _downloader.taskQueues) { + taskQueue.resumeAll(tasks: nTasks, group: nGroup); + } + return results; + } + + /// Set WiFi requirement globally, based on [requirement]. + /// + /// Affects future tasks and reschedules enqueued, inactive tasks + /// (both downloads and uploads) with the new setting. + /// Reschedules running download tasks if [rescheduleRunningTasks] is true, + /// otherwise leaves those running with their prior setting. + /// Restarts running upload tasks if [alsoRestartUploads] is true (which + /// requires [rescheduleRunningTasks] to be true as well). + /// + /// Note that on Android (API 28+), requiring WiFi enforces a Wi-Fi network constraint + /// (`TRANSPORT_WIFI`), preventing tasks from running over cellular connections even if unmetered. + /// On older Android versions, it enforces an unmetered network constraint (`NETWORK_TYPE_UNMETERED`). + /// On iOS, requiring WiFi disables cellular access (`allowsCellularAccess = false`), allowing + /// execution over any Wi-Fi or Ethernet connection. + Future requireWiFi( + RequireWiFi requirement, { + rescheduleRunningTasks = true, + alsoRestartUploads = false, + }) { + assert( + !(alsoRestartUploads && !rescheduleRunningTasks), + 'alsoRestartUploads can only be set if rescheduleRunningTasks is true', + ); + return _downloader.requireWiFi( + requirement, + rescheduleRunningTasks, + alsoRestartUploads, + ); + } + + /// Returns the current global setting for requiring WiFi + Future getRequireWiFiSetting() => + _downloader.getRequireWiFiSetting(); + + /// Configure notification for a single task + /// + /// The configuration determines what notifications are shown, + /// whether a progress bar is shown (Android only), and whether tapping + /// the 'complete' notification opens the downloaded file. + /// + /// [running] is the notification used while the task is in progress + /// [complete] is the notification used when the task completed + /// [error] is the notification used when something went wrong, + /// including failed and notFound status + /// [paused] is the notification shown when the task is paused + /// [canceled] is the notification shown when the task is canceled programmatically + /// or by the user via a notification button + /// [progressBar] if set will show a progress bar + /// [tapOpensFile] if set will attempt to open the file when the [complete] + /// notification is tapped + /// [groupNotificationId] if set will group all notifications with the same + /// [groupNotificationId] and change the progress bar to number of finished + /// tasks versus total number of tasks in the [groupNotificationId]. + /// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title] + /// and [TaskNotification.body] to substitute. Task-specific substitutions + /// such as {filename} are not valid when using [groupNotificationId]. + /// The [groupNotificationId] is considered [complete] when there are no + /// more tasks running within that group, and at that point the + /// [complete] notification is shown (if configured). If any task in the + /// [groupNotificationId] fails, the [error] notification is shown. + /// The first character of the [groupNotificationId] cannot be '*'. + /// + /// The [TaskNotification] is the actual notification shown for a [Task], and + /// [TaskNotification.body] and [TaskNotification.title] may contain special strings to substitute display values: + /// {filename} to insert the [Task.filename] + /// {metaData} to insert the [Task.metaData] + /// {displayName} to insert the [Task.displayName] + /// {progress} to insert progress in % + /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A + /// {timeRemaining} to insert the estimated time remaining to complete the task + /// in HH:MM:SS or MM:SS or --:-- if N/A + /// {numFinished} to insert the number of finished tasks in a groupNotification + /// {numFailed} to insert the number of failed tasks in a groupNotification + /// {numTotal} to insert the number of tasks in a groupNotification + /// + /// Actual appearance of notification is dependent on the platform, e.g. + /// on iOS {progress} is not available and ignored (except for groupNotifications) + /// + /// Returns the [FileDownloader] for easy chaining + FileDownloader configureNotificationForTask( + Task task, { + TaskNotification? running, + TaskNotification? complete, + TaskNotification? error, + TaskNotification? paused, + TaskNotification? canceled, + bool progressBar = false, + bool tapOpensFile = false, + String groupNotificationId = '', + }) { + _addOrUpdateTaskNotificationConfig( + TaskNotificationConfig( + taskOrGroup: withNamespacedGroup(task), + running: running, + complete: complete, + error: error, + paused: paused, + canceled: canceled, + progressBar: progressBar, + tapOpensFile: tapOpensFile, + groupNotificationId: groupNotificationId, + ), + ); + return this; + } + + /// Configure notification for a group of tasks + /// + /// The configuration determines what notifications are shown, + /// whether a progress bar is shown (Android only), and whether tapping + /// the 'complete' notification opens the downloaded file. + /// + /// [running] is the notification used while the task is in progress + /// [complete] is the notification used when the task completed + /// [error] is the notification used when something went wrong, + /// including failed and notFound status + /// [paused] is the notification shown when the task is paused + /// [canceled] is the notification shown when the task is canceled programmatically + /// or by the user via a notification button + /// [progressBar] if set will show a progress bar + /// [tapOpensFile] if set will attempt to open the file when the [complete] + /// notification is tapped + /// [groupNotificationId] if set will group all notifications with the same + /// [groupNotificationId] and change the progress bar to number of finished + /// tasks versus total number of tasks in the [groupNotificationId]. + /// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title] + /// and [TaskNotification.body] to substitute. Task-specific substitutions + /// such as {filename} are not valid when using [groupNotificationId]. + /// The [groupNotificationId] is considered [complete] when there are no + /// more tasks running within that group, and at that point the + /// [complete] notification is shown (if configured). If any task in the + /// [groupNotificationId] fails, the [error] notification is shown. + /// The first character of the [groupNotificationId] cannot be '*'. + /// + /// The [TaskNotification] is the actual notification shown for a [Task], and + /// [TaskNotification.body] and [TaskNotification.title] may contain special strings to substitute display values: + /// {filename} to insert the [Task.filename] + /// {metaData} to insert the [Task.metaData] + /// {displayName} to insert the [Task.displayName] + /// {progress} to insert progress in % + /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A + /// {timeRemaining} to insert the estimated time remaining to complete the task + /// in HH:MM:SS or MM:SS or --:-- if N/A + /// {numFinished} to insert the number of finished tasks in a groupNotification + /// {numFailed} to insert the number of failed tasks in a groupNotification + /// {numTotal} to insert the number of tasks in a groupNotification + /// + /// Actual appearance of notification is dependent on the platform, e.g. + /// on iOS {progress} is not available and ignored (except for groupNotifications) + /// + /// Returns the [FileDownloader] for easy chaining + FileDownloader configureNotificationForGroup( + String group, { + TaskNotification? running, + TaskNotification? complete, + TaskNotification? error, + TaskNotification? paused, + TaskNotification? canceled, + bool progressBar = false, + bool tapOpensFile = false, + String groupNotificationId = '', + }) { + _addOrUpdateTaskNotificationConfig( + TaskNotificationConfig( + taskOrGroup: namespacedGroup(group), + running: running, + complete: complete, + error: error, + paused: paused, + canceled: canceled, + progressBar: progressBar, + tapOpensFile: tapOpensFile, + groupNotificationId: groupNotificationId, + ), + ); + return this; + } + + /// Configure default task notification + /// + /// The configuration determines what notifications are shown, + /// whether a progress bar is shown (Android only), and whether tapping + /// the 'complete' notification opens the downloaded file. + /// + /// [running] is the notification used while the task is in progress + /// [complete] is the notification used when the task completed + /// [error] is the notification used when something went wrong, + /// including failed and notFound status + /// [paused] is the notification shown when the task is paused + /// [canceled] is the notification shown when the task is canceled programmatically + /// or by the user via a notification button + /// [progressBar] if set will show a progress bar + /// [tapOpensFile] if set will attempt to open the file when the [complete] + /// notification is tapped + /// [groupNotificationId] if set will group all notifications with the same + /// [groupNotificationId] and change the progress bar to number of finished + /// tasks versus total number of tasks in the [groupNotificationId]. + /// Use {numFinished} and {numTotal} tokens in the [TaskNotification.title] + /// and [TaskNotification.body] to substitute. Task-specific substitutions + /// such as {filename} are not valid when using [groupNotificationId]. + /// The [groupNotificationId] is considered [complete] when there are no + /// more tasks running within that group, and at that point the + /// [complete] notification is shown (if configured). If any task in the + /// [groupNotificationId] fails, the [error] notification is shown. + /// The first character of the [groupNotificationId] cannot be '*'. + /// + /// The [TaskNotification] is the actual notification shown for a [Task], and + /// [TaskNotification.body] and [TaskNotification.title] may contain special + /// strings to substitute display values: + /// {filename} to insert the [Task.filename] + /// {metaData} to insert the [Task.metaData] + /// {displayName} to insert the [Task.displayName] + /// {progress} to insert progress in % + /// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A + /// {timeRemaining} to insert the estimated time remaining to complete the task + /// in HH:MM:SS or MM:SS or --:-- if N/A + /// {numFinished} to insert the number of finished tasks in a groupNotification + /// {numFailed} to insert the number of failed tasks in a groupNotification + /// {numTotal} to insert the number of tasks in a groupNotification + /// + /// Actual appearance of notification is dependent on the platform, e.g. + /// on iOS {progress} is not available and ignored (except for groupNotifications) + /// + /// Returns the [FileDownloader] for easy chaining + FileDownloader configureNotification({ + TaskNotification? running, + TaskNotification? complete, + TaskNotification? error, + TaskNotification? paused, + TaskNotification? canceled, + bool progressBar = false, + bool tapOpensFile = false, + String groupNotificationId = '', + }) { + _addOrUpdateTaskNotificationConfig( + TaskNotificationConfig( + taskOrGroup: null, + running: running, + complete: complete, + error: error, + paused: paused, + canceled: canceled, + progressBar: progressBar, + tapOpensFile: tapOpensFile, + groupNotificationId: groupNotificationId, + ), + ); + return this; + } + + /// Helper to add or update a task notification config + void _addOrUpdateTaskNotificationConfig( + TaskNotificationConfig taskNotificationConfig, + ) { + _downloader.notificationConfigs.remove(taskNotificationConfig); + _downloader.notificationConfigs.add(taskNotificationConfig); + } + + /// Returns the [TaskNotificationConfig] configured for [task], if any + TaskNotificationConfig? notificationConfigForTask(Task task) => + _downloader.notificationConfigForTask(task); + + /// Perform a server request for this [request] + /// + /// A server request returns an [http.Response] object that includes + /// the [http.Response.body] as String, the [http.Response.bodyBytes] as [Uint8List] and the [json] + /// representation if available. + /// It also contains the [http.Response.statusCode] and [http.Response.reasonPhrase] that may indicate + /// an error, and several other fields that may be useful. + /// A local error (e.g. a SocketException) will yield [http.Response.statusCode] 499, with + /// details in the [http.Response.reasonPhrase] + /// + /// The request will abide by the [Request.retries] set on the [request], and set + /// [Request.headers] included in the [request]. Redirects are followed automatically + /// (up to 10 redirects). + /// + /// The [http.Client] object used for this request is the [DesktopDownloader.httpClient] field of + /// the downloader. If not set, the default [http.Client] will be used. + /// The request is executed on an Isolate, to ensure minimal interference + /// with the main Isolate + Future request(Request request) => compute(_doRequest, ( + request, + DesktopDownloader.requestTimeout, + DesktopDownloader.proxy, + DesktopDownloader.bypassTLSCertificateValidation, + DesktopDownloader.mtlsConfigs, + )); + + /// Move the file represented by the [task] to a shared storage + /// [destination] and potentially a [directory] within that destination. If + /// the [mimeType] is not provided we will attempt to derive it from the + /// [Task.filePath] extension + /// + /// Returns the path to the stored file, or null if not successful. + /// + /// NOTE: on iOS, using [destination] [SharedStorage.images] or + /// [SharedStorage.video] adds the photo or video file to the Photos + /// library. This requires the user to grant permission, and requires the + /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The + /// returned value is NOT a filePath but an identifier. If the full filepath + /// is required, follow the [moveToSharedStorage] call with a call to + /// [pathInSharedStorage], passing the identifier obtained from the call + /// to [moveToSharedStorage] as the filePath parameter. This requires the user to + /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription" + /// key to be set in Info.plist. The returned value is the actual file path + /// of the photo or video in the Photos Library. + /// + /// Platform-dependent, not consistent across all platforms + Future moveToSharedStorage( + DownloadTask task, + SharedStorage destination, { + String directory = '', + String? mimeType, + }) async => moveFileToSharedStorage( + await task.filePath(), + destination, + directory: directory, + mimeType: mimeType, + ); + + /// Move the file represented by [filePath] to a shared storage + /// [destination] and potentially a [directory] within that destination. If + /// the [mimeType] is not provided we will attempt to derive it from the + /// [filePath] extension + /// + /// Returns the path to the stored file, or null if not successful + /// + /// NOTE: on iOS, using [destination] [SharedStorage.images] or + /// [SharedStorage.video] adds the photo or video file to the Photos + /// library. This requires the user to grant permission, and requires the + /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The + /// returned value is NOT a filePath but an identifier. If the full filepath + /// is required, follow the [moveToSharedStorage] call with a call to + /// [pathInSharedStorage], passing the identifier obtained from the call + /// to [moveToSharedStorage] as the filePath parameter. This requires the user to + /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription" + /// key to be set in Info.plist. The returned value is the actual file path + /// of the photo or video in the Photos Library. + /// + /// Platform-dependent, not consistent across all platforms + Future moveFileToSharedStorage( + String filePath, + SharedStorage destination, { + String directory = '', + String? mimeType, + }) async => _downloader.moveToSharedStorage( + filePath, + destination, + directory, + mimeType, + ); + + /// Returns the filePath to the file represented by [filePath] in shared + /// storage [destination] and potentially a [directory] within that + /// destination. + /// + /// Returns the path to the stored file, or null if not successful + /// + /// See the documentation for [moveToSharedStorage] for special use case + /// on iOS for .images and .video + /// + /// Platform-dependent, not consistent across all platforms + Future pathInSharedStorage( + String filePath, + SharedStorage destination, { + String directory = '', + }) async => _downloader.pathInSharedStorage(filePath, destination, directory); + + /// Open the file represented by [task] or [filePath] using the application + /// available on the platform. + /// + /// [mimeType] may override the mimetype derived from the file extension, + /// though implementation depends on the platform and may not always work. + /// + /// Returns true if an application was launched successfully + Future openFile({Task? task, String? filePath, String? mimeType}) { + assert(task != null || filePath != null, 'Task or filePath must be set'); + assert( + !(task != null && filePath != null), + 'Either task or filePath must be set, not both', + ); + return _downloader.openFile(task, filePath, mimeType); + } + + /// Return the platform version as a String + /// + /// On Android this is the API integer, e.g. "33" + /// On iOS this is the iOS version, e.g. "16.1" + /// On desktop this is a description of the OS version, not parsable + Future platformVersion() => _downloader.platformVersion(); + + /// Closes the [updates] stream and re-initializes the [StreamController] + /// such that the stream can be listened to again + Future resetUpdates() => _downloader.resetUpdatesStreamController(); + + /// Destroy the [FileDownloader]. Subsequent use requires initialization + void destroy() { + transfers.clear(); + transfers.dispose(); + _registeredStatusCallbacks.clear(); + _registeredProgressCallbacks.clear(); + _registeredNotificationTapCallbacks.clear(); + _scopedInstances.clear(); + _downloader.destroy(); + try { + Localstore.instance.clearCache(); + } catch (_) {} + } +} + +/// Performs the actual server request, with retries +/// +/// This function is run on an Isolate to ensure performance on the main +/// Isolate is not affected +Future _doRequest( + (Request, Duration?, Map, bool, List) params, +) async { + final ( + request, + requestTimeout, + proxy, + bypassTLSCertificateValidation, + mtlsConfigs, + ) = params; + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((LogRecord rec) { + if (kDebugMode) { + print('${rec.loggerName}>${rec.level.name}: ${rec.time}: ${rec.message}'); + } + }); + final log = Logger('FileDownloader.request'); + DesktopDownloader.setHttpClient( + requestTimeout, + proxy, + bypassTLSCertificateValidation, + mtlsConfigs, + ); + final client = DesktopDownloader.httpClientForUrl(request.url); + var response = http.Response( + '', + 499, + reasonPhrase: 'Not attempted', + ); // dummy to start with + while (request.retriesRemaining >= 0) { + try { + response = await switch (request.httpRequestMethod) { + 'GET' => client.get(Uri.parse(request.url), headers: request.headers), + 'POST' => client.post( + Uri.parse(request.url), + headers: request.headers, + body: request.postBody, + ), + 'HEAD' => client.head(Uri.parse(request.url), headers: request.headers), + 'PUT' => client.put( + Uri.parse(request.url), + headers: request.headers, + body: request.postBody, + ), + 'DELETE' => client.delete( + Uri.parse(request.url), + headers: request.headers, + body: request.postBody, + ), + 'PATCH' => client.patch( + Uri.parse(request.url), + headers: request.headers, + body: request.postBody, + ), + _ => Future.value(response), + }; + if ([ + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 404, + ].contains(response.statusCode)) { + return response; + } + } catch (e) { + log.warning(e); + response = http.Response('', 499, reasonPhrase: e.toString()); + } + // error, retry if allowed + request.decreaseRetriesRemaining(); + if (request.retriesRemaining < 0) { + return response; // final response with error + } + final waitTime = Duration( + seconds: pow(2, (request.retries - request.retriesRemaining)).toInt(), + ); + await Future.delayed(waitTime); + } + throw ArgumentError('Request to ${request.url} had no retries remaining'); +} diff --git a/third_party/background_downloader/lib/src/json_processor.dart b/third_party/background_downloader/lib/src/json_processor.dart new file mode 100644 index 0000000..941b5f8 --- /dev/null +++ b/third_party/background_downloader/lib/src/json_processor.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'base_downloader.dart'; +import 'models.dart'; +import 'task.dart'; + +/// Commands to be executed by the background isolate +sealed class JsonCommand { + final int id; + const JsonCommand(this.id); +} + +class _TaskFromJson extends JsonCommand { + final String jsonString; + const _TaskFromJson(super.id, this.jsonString); +} + +class _DownloadTaskListFromJson extends JsonCommand { + final String jsonString; + const _DownloadTaskListFromJson(super.id, this.jsonString); +} + +class _TaskListFromListStrings extends JsonCommand { + final List jsonStrings; + const _TaskListFromListStrings(super.id, this.jsonStrings); +} + +class _TaskAndNotificationConfigJsonStrings extends JsonCommand { + final List tasks; + final Set notificationConfigs; + const _TaskAndNotificationConfigJsonStrings( + super.id, + this.tasks, + this.notificationConfigs, + ); +} + +/// Singleton object that manages a background isolate for JSON encoding/decoding +class JsonProcessor { + static final JsonProcessor _instance = JsonProcessor._internal(); + + factory JsonProcessor() => _instance; + + JsonProcessor._internal(); + + Isolate? _isolate; + SendPort? _sendPort; + final Map> _pendingCompleters = {}; + Timer? _shutdownTimer; + int _nextId = 0; + bool _isStarting = false; + Completer _startCompleter = Completer(); + + /// Public API + + Future decodeTask(String jsonString) async => + await _process((id) => _TaskFromJson(id, jsonString)); + + Future> decodeDownloadTaskList(String jsonString) async => + await _process>( + (id) => _DownloadTaskListFromJson(id, jsonString), + ); + + Future> decodeTaskList(List jsonStrings) async => + await _process>( + (id) => _TaskListFromListStrings(id, jsonStrings), + ); + + Future<(String, String)> encodeTaskAndNotificationConfig( + Iterable tasks, + Set notificationConfigs, + ) async { + // Convert Iterable to List to ensure it's sendable and fixed + final taskList = tasks.toList(); + return await _process<(String, String)>( + (id) => _TaskAndNotificationConfigJsonStrings( + id, + taskList, + notificationConfigs, + ), + ); + } + + /// Internal processing logic + + Future _process(JsonCommand Function(int id) commandBuilder) async { + await _ensureStarted(); + _resetShutdownTimer(); + + final id = _nextId++; + final completer = Completer(); + _pendingCompleters[id] = completer; + + final command = commandBuilder(id); + _sendPort!.send(command); + + try { + final result = await completer.future; + return result; + } catch (e) { + rethrow; + } finally { + _resetShutdownTimer(); // Reset timer on completion too + } + } + + Future _ensureStarted() async { + if (_isolate != null && _sendPort != null) return; + + if (_isStarting) { + await _startCompleter.future; + return; + } + + _isStarting = true; + _startCompleter = Completer(); + + try { + final receivePort = ReceivePort(); + _isolate = await Isolate.spawn(_isolateMain, receivePort.sendPort); + + // Wait for the isolate to send its SendPort + _sendPort = await receivePort.first as SendPort; + + // Create a new receive port for responses + final responsePort = ReceivePort(); + _sendPort!.send(responsePort.sendPort); + + responsePort.listen(_handleResponse); + + if (!_startCompleter.isCompleted) { + _startCompleter.complete(); + } + } catch (e) { + _isolate?.kill(); + _isolate = null; + _sendPort = null; + _isStarting = false; + if (!_startCompleter.isCompleted) { + _startCompleter.completeError(e); + } + rethrow; + } finally { + _isStarting = false; + } + } + + void _handleResponse(dynamic message) { + if (message is _JsonResponse) { + final completer = _pendingCompleters.remove(message.id); + if (completer != null) { + if (message.error != null) { + completer.completeError(message.error!); + } else { + completer.complete(message.result); + } + } + } + } + + void _resetShutdownTimer() { + _shutdownTimer?.cancel(); + _shutdownTimer = Timer(const Duration(minutes: 1), _shutdown); + } + + void _shutdown() { + if (_pendingCompleters.isNotEmpty) { + // Defer shutdown while requests are still in flight + _resetShutdownTimer(); + return; + } + _isolate?.kill(); + _isolate = null; + _sendPort = null; + _shutdownTimer?.cancel(); + _shutdownTimer = null; + } +} + +class _JsonResponse { + final int id; + final dynamic result; + final Object? error; + + _JsonResponse(this.id, this.result, this.error); +} + +void _isolateMain(SendPort mainSendPort) { + final receivePort = ReceivePort(); + mainSendPort.send(receivePort.sendPort); + + SendPort? replyPort; + + receivePort.listen((message) async { + if (message is SendPort) { + replyPort = message; + } else if (message is JsonCommand) { + if (replyPort == null) { + return; // Should not happen if protocol is followed + } + + try { + final result = await _executeCommand(message); + replyPort!.send(_JsonResponse(message.id, result, null)); + } catch (e) { + replyPort!.send(_JsonResponse(message.id, null, e)); + } + } + }); +} + +Future _executeCommand(JsonCommand command) async { + switch (command) { + case final _TaskFromJson c: + return Task.createFromJson(jsonDecode(c.jsonString)); + + case final _DownloadTaskListFromJson c: + return (jsonDecode(c.jsonString) as List) + .map((e) => Task.createFromJson(e as Map)) + .cast() + .toList(); + + case final _TaskListFromListStrings c: + return c.jsonStrings + .map((e) => Task.createFromJson(jsonDecode(e as String))) + .toList(); + + case final _TaskAndNotificationConfigJsonStrings c: + final tasksJsonString = jsonEncode(c.tasks); + final configs = c.tasks + .map( + (task) => BaseDownloader.notificationConfigForTaskUsingConfigSet( + task, + c.notificationConfigs, + ), + ) + .toList(); + final notificationConfigsJsonString = jsonEncode(configs); + return (tasksJsonString, notificationConfigsJsonString); + } +} diff --git a/third_party/background_downloader/lib/src/localstore/collection_ref.dart b/third_party/background_downloader/lib/src/localstore/collection_ref.dart new file mode 100644 index 0000000..e078693 --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/collection_ref.dart @@ -0,0 +1,100 @@ +part of 'localstore.dart'; + +/// A [CollectionRef] object can be used for adding documents, getting +/// [DocumentRef]s, and querying for documents. +final class CollectionRef implements CollectionRefImpl { + String _id; + + /// A string representing the path of the referenced document (relative to the + /// root of the database). + String get path => _path; + + String _path = ''; + + DocumentRef? _delegate; + + CollectionRef? _parent; + + List? _conditions; + + static final pathSeparatorRegEx = RegExp(r'[/\\]'); + + /// The parent [CollectionRef] of this document. + CollectionRef? get parent => _parent; + + CollectionRef._(this._id, [this._parent, this._delegate, this._conditions]) { + _path = _buildPath(_parent?.path, _id, _delegate?.id); + } + static final _cache = {}; + + /// Returns an instance using the default [CollectionRef]. + factory CollectionRef( + String id, [ + CollectionRef? parent, + DocumentRef? delegate, + List? conditions, + ]) { + final key = _buildPath(parent?.path, id, delegate?.id); + final collectionRef = _cache.putIfAbsent( + key, + () => CollectionRef._(id, parent, delegate, conditions), + ); + collectionRef._conditions = conditions; + return collectionRef; + } + + static String _buildPath(String? parentPath, String path, String? docId) { + final docPath = ((docId != null && parentPath != null) + ? '$docId.collection' + : ''); + final pathSep = p.separator; + return '${parentPath ?? ''}$docPath$pathSep$path$pathSep'; + } + + final _utils = Utils.instance; + + @override + Stream> get stream => _utils.stream(path, _conditions); + + @override + Future?> get() async => + await _utils.get(path, true, _conditions); + + @override + DocumentRef doc([String? id]) { + id ??= int.parse( + '${Random().nextInt(1000000000)}${Random().nextInt(1000000000)}', + ).toRadixString(35).substring(0, 9); + return DocumentRef(id, this); + } + + @override + CollectionRef where(field, {isEqualTo}) { + final conditions = []; + void addCondition(dynamic field, String operator, dynamic value) { + List condition; + + condition = [field, operator, value]; + conditions.add(condition); + } + + if (isEqualTo != null) addCondition(field, '==', isEqualTo); + + _conditions = conditions; + + return this; + } + + @override + Future delete() async { + final docs = await _utils.get(path, true, _conditions); + if (docs != null) { + for (final key in docs.keys) { + final id = key.split(pathSeparatorRegEx).last; + DocumentRef(id, this)._data.clear(); + } + } + + await _utils.delete(path); + } +} diff --git a/third_party/background_downloader/lib/src/localstore/collection_ref_impl.dart b/third_party/background_downloader/lib/src/localstore/collection_ref_impl.dart new file mode 100644 index 0000000..7b46cc5 --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/collection_ref_impl.dart @@ -0,0 +1,29 @@ +part of 'localstore.dart'; + +/// The interface that other CollectionRef must extend. +abstract class CollectionRefImpl { + /// Returns a `DocumentRef` with the provided id. + /// + /// If no [id] is provided, an auto-generated ID is used. + /// + /// The unique key generated is prefixed with a client-generated timestamp + /// so that the resulting list will be chronologically-sorted. + DocumentRef doc([String? id]); + + /// Notifies of query results at this collection. + Stream> get stream; + + /// Fetch the documents for this collection + Future?> get(); + + /// Creates and returns a new [CollectionRef] with additional filter on + /// specified [field]. [field] refers to a field in a document. + /// + /// `where` is not implemented + CollectionRef where(dynamic field, {dynamic isEqualTo}); + + /// Delete collection + /// + /// All collections and documents in this collection will be deleted. + Future delete(); +} diff --git a/third_party/background_downloader/lib/src/localstore/document_ref.dart b/third_party/background_downloader/lib/src/localstore/document_ref.dart new file mode 100644 index 0000000..9d711da --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/document_ref.dart @@ -0,0 +1,66 @@ +part of 'localstore.dart'; + +/// A [DocumentRef] refers to a document location in a [Localstore] database +/// and can be used to write, read, or listen to the location. +/// +/// The document at the referenced location may or may not exist. +/// A [DocumentRef] can also be used to create a [CollectionRef] +/// to a subcollection. +final class DocumentRef implements DocumentRefImpl { + String _id; + + /// This document's given ID within the collection. + String get id => _id; + + CollectionRef? _delegate; + + DocumentRef._(this._id, [this._delegate]); + + static final _cache = {}; + + /// Returns an instance using the default [DocumentRef]. + factory DocumentRef(String id, [CollectionRef? delegate]) { + final key = '${delegate?.path ?? ''}$id'; + return _cache.putIfAbsent(key, () => DocumentRef._(id, delegate)); + } + + /// A string representing the path of the referenced document (relative to the + /// root of the database). + String get path => '${_delegate?.path}$id'; + + final _utils = Utils.instance; + + final Map _data = {}; + + @override + Future set(Map data, [SetOptions? options]) async { + options ??= SetOptions(); + if (options.merge) { + final output = Map.from(data); + final Map? input = _data[id] ?? {}; + output.updateAll((key, value) { + input![key] = value; + }); + _data[id] = input; + } else { + _data[id] = data; + } + return await _utils.set(_data[id], path); + } + + @override + Future?> get() async => + _data[id] ?? await _utils.get(path); + + @override + Future delete() async { + await _utils.delete(path); + _data.remove(id); + } + + @override + CollectionRef collection(String id) => CollectionRef(id, _delegate, this); + + @override + String toString() => _utils.toString(); +} diff --git a/third_party/background_downloader/lib/src/localstore/document_ref_impl.dart b/third_party/background_downloader/lib/src/localstore/document_ref_impl.dart new file mode 100644 index 0000000..cf87238 --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/document_ref_impl.dart @@ -0,0 +1,20 @@ +part of 'localstore.dart'; + +/// The interface that other DocumentRef must extend. +abstract class DocumentRefImpl { + /// Gets a [CollectionRef] for the specified Localstore path. + CollectionRef collection(String path); + + /// Sets data on the document, overwriting any existing data. If the document + /// does not yet exist, it will be created. + /// + /// If [SetOptions] are provided, the data will be merged into an existing + /// document instead of overwriting. + Future set(Map data, [SetOptions? options]); + + /// Reads the document referenced by this [DocumentRef]. + Future?> get(); + + /// Deletes the current document from the collection. + Future delete(); +} diff --git a/third_party/background_downloader/lib/src/localstore/localstore.dart b/third_party/background_downloader/lib/src/localstore/localstore.dart new file mode 100644 index 0000000..290689b --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/localstore.dart @@ -0,0 +1,19 @@ +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'dart:math'; + +import 'utils/html.dart' if (dart.library.io) 'utils/io.dart'; + +part 'collection_ref.dart'; +part 'collection_ref_impl.dart'; +part 'document_ref.dart'; +part 'document_ref_impl.dart'; +part 'set_option.dart'; +part 'localstore_base.dart'; +part 'localstore_impl.dart'; diff --git a/third_party/background_downloader/lib/src/localstore/localstore_base.dart b/third_party/background_downloader/lib/src/localstore/localstore_base.dart new file mode 100644 index 0000000..b01383b --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/localstore_base.dart @@ -0,0 +1,30 @@ +part of 'localstore.dart'; + +/// The entry point for accessing a [Localstore]. +/// +/// You can get an instance by calling [Localstore.instance], for example: +/// +/// ```dart +/// final db = Localstore.instance; +/// ``` +final class Localstore implements LocalstoreImpl { + final _databaseDirectory = getApplicationSupportDirectory(); + final _delegate = DocumentRef._(''); + static final Localstore _localstore = Localstore._(); + + /// Private initializer + Localstore._(); + + /// Returns an instance using the default [Localstore]. + static Localstore get instance => _localstore; + + Future get databaseDirectory => _databaseDirectory; + + /// Clears the cache - needed only if filesystem has been manipulated directly + void clearCache() { + Utils.instance.clearCache(); + } + + @override + CollectionRef collection(String path) => CollectionRef(path, null, _delegate); +} diff --git a/third_party/background_downloader/lib/src/localstore/localstore_impl.dart b/third_party/background_downloader/lib/src/localstore/localstore_impl.dart new file mode 100644 index 0000000..f045a70 --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/localstore_impl.dart @@ -0,0 +1,7 @@ +part of 'localstore.dart'; + +/// The interface that other Localstore must extend. +abstract class LocalstoreImpl { + /// Gets a [CollectionRef] for the specified Localstore path. + CollectionRef collection(String path); +} diff --git a/third_party/background_downloader/lib/src/localstore/set_option.dart b/third_party/background_downloader/lib/src/localstore/set_option.dart new file mode 100644 index 0000000..5a27fea --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/set_option.dart @@ -0,0 +1,14 @@ +part of 'localstore.dart'; + +/// An options class that configures the behavior of set() calls in +/// [DocumentRef]. +final class SetOptions { + final bool _merge; + + /// Changes the behavior of a set() call to only replace the values specified + /// in its data argument. + bool get merge => _merge; + + /// Creates a [SetOptions] instance. + SetOptions({this._merge = false}); +} diff --git a/third_party/background_downloader/lib/src/localstore/utils/html.dart b/third_party/background_downloader/lib/src/localstore/utils/html.dart new file mode 100644 index 0000000..6ff7481 --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/utils/html.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'utils_impl.dart'; + +/// Utils class +class Utils implements UtilsImpl { + Utils._(); + + static final Utils _utils = Utils._(); + + static Utils get instance => _utils; + + @override + void clearCache() { + throw UnimplementedError('Web is not supported'); + } + + @override + Future?> get( + String path, [ + bool? isCollection = false, + List? conditions, + ]) async { + throw UnimplementedError('Web is not supported'); + } + + @override + Future? set(Map data, String path) { + throw UnimplementedError('Web is not supported'); + } + + @override + Future delete(String path) async { + throw UnimplementedError('Web is not supported'); + } + + @override + Stream> stream(String path, [List? conditions]) { + throw UnimplementedError('Web is not supported'); + } +} diff --git a/third_party/background_downloader/lib/src/localstore/utils/io.dart b/third_party/background_downloader/lib/src/localstore/utils/io.dart new file mode 100644 index 0000000..1580ff3 --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/utils/io.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; + +import '../localstore.dart'; +import 'utils_impl.dart'; + +import 'package:logging/logging.dart'; + +final _log = Logger('Localstore'); + +final class Utils implements UtilsImpl { + Utils._(); + + static final Utils _utils = Utils._(); + static final lastPathComponentRegEx = RegExp(r'[^/\\]+[/\\]?$'); + + static Utils get instance => _utils; + final _storageCache = >>{}; + final _fileCache = {}; + final _locks = >{}; + + /// Clears the cache + @override + void clearCache() { + _storageCache.clear(); + _fileCache.clear(); + } + + Future _synchronized(String path, Future Function() action) async { + final previous = _locks[path] ?? Future.value(); + final controller = Completer(); + + final newFuture = previous + .then((_) async { + try { + final result = await action(); + controller.complete(result); + } catch (e, st) { + controller.completeError(e, st); + } + }) + .catchError((_) {}); + + _locks[path] = newFuture; + + // Cleanup + newFuture.whenComplete(() { + if (_locks[path] == newFuture) { + _locks.remove(path); + } + }); + + return controller.future; + } + + String _resolvePath(String dbPath, String path) { + var relativePath = path; + if (relativePath.startsWith('/') || relativePath.startsWith('\\')) { + relativePath = relativePath.substring(1); + } + return p.join(dbPath, relativePath); + } + + @override + Future?> get( + String path, [ + bool? isCollection = false, + List? conditions, + ]) async { + // Fetch the documents for this collection + if (isCollection != null && isCollection == true) { + final dbDir = await Localstore.instance.databaseDirectory; + final fullPath = _resolvePath(dbDir.path, path); + final dir = Directory(fullPath); + if (!await dir.exists()) { + return {}; + } + final entries = await dir.list(recursive: false).toList(); + return await _getAll(entries.whereType().toList()); + } else { + return _synchronized(path, () async { + try { + // Reads the document referenced by this [DocumentRef]. + final file = await _getFile(path); + final randomAccessFile = await file!.open(mode: FileMode.append); + try { + final data = await _readFile(randomAccessFile); + if (data is Map) { + final key = path.replaceAll(lastPathComponentRegEx, ''); + // ignore: close_sinks + final storage = _storageCache.putIfAbsent( + key, + () => _newStream(key), + ); + storage.add(data); + return data; + } + } finally { + await randomAccessFile.close(); + } + } on PathNotFoundException { + // return null if not found + } + return null; + }); + } + } + + @override + Future? set(Map data, String path) => + _writeFile(data, path); + + @override + Future delete(String path) async { + if (path.endsWith(Platform.pathSeparator)) { + await _deleteDirectory(path); + } else { + await _synchronized(path, () => _deleteFile(path)); + } + } + + @override + Stream> stream(String path, [List? conditions]) { + // ignore: close_sinks + var storage = _storageCache[path]; + if (storage == null) { + storage = _storageCache.putIfAbsent(path, () => _newStream(path)); + } else { + _initStream(storage, path); + } + return storage.stream; + } + + Future?> _getAll(List entries) async { + _log.finest('Getting all entries from ${entries.length} files'); + final items = {}; + final dbDir = await Localstore.instance.databaseDirectory; + for (final e in entries) { + final relativePath = p.relative(e.path, from: dbDir.path); + final path = Platform.isWindows + ? relativePath.replaceAll(p.separator, '/') + : relativePath; + + await _synchronized(path, () async { + final file = await _getFile(path); + try { + final randomAccessFile = await file!.open(mode: FileMode.append); + try { + final data = await _readFile(randomAccessFile); + if (data is Map) { + items[path] = data; + } + } finally { + await randomAccessFile.close(); + } + } on PathNotFoundException { + // ignore if not found + } catch (e) { + _log.warning('Error reading file $path: $e'); + } + }); + } + + if (items.isEmpty) return null; + return items; + } + + /// Streams all file in the path + StreamController> _newStream(String path) { + final storage = StreamController>.broadcast(); + _initStream(storage, path); + + return storage; + } + + Future _initStream( + StreamController> storage, + String path, + ) async { + final dbDir = await Localstore.instance.databaseDirectory; + final fullPath = _resolvePath(dbDir.path, path); + final dir = Directory(fullPath); + try { + if (!await dir.exists()) return; + final entries = await dir.list(recursive: false).toList(); + for (final e in entries) { + if (e is! File) continue; + final relativePath = p.relative(e.path, from: dbDir.path); + final filePath = Platform.isWindows + ? relativePath.replaceAll(p.separator, '/') + : relativePath; + + // We use synchronized reading + await _synchronized(filePath, () async { + final file = await _getFile(filePath); + final randomAccessFile = await file!.open(mode: FileMode.append); + try { + final data = await _readFile(randomAccessFile); + if (data is Map) { + storage.add(data); + } + } finally { + await randomAccessFile.close(); + } + }); + } + } catch (e) { + // ignore + } + } + + Future _readFile(RandomAccessFile file) async { + final length = await file.length(); + await file.setPosition(0); + final buffer = Uint8List(length); + await file.readInto(buffer); + try { + final contentText = utf8.decode(buffer); + final data = json.decode(contentText) as Map; + return data; + } catch (e) { + return e; + } + } + + Future _getFile(String path) async { + final dbDir = await Localstore.instance.databaseDirectory; + final file = _fileCache[path] ?? File(_resolvePath(dbDir.path, path)); + + if (!await file.exists()) { + try { + await file.create(recursive: true); + } on FileSystemException { + // Handle race condition where directory was being deleted/modified concurrently + await Future.delayed(const Duration(milliseconds: 50)); + if (!await file.exists()) { + try { + await file.create(recursive: true); + } on FileSystemException catch (e) { + _log.warning('Could not create file $path: $e'); + _fileCache.remove(path); + return null; + } + } + } + } + _fileCache[path] = file; + + return file; + } + + Future _writeFile(Map data, String path) => + _synchronized(path, () async { + final serialized = json.encode(data); + final buffer = utf8.encode(serialized); + try { + final file = await _getFile(path); + if (file == null) return; + final randomAccessFile = await file.open(mode: FileMode.append); + try { + await randomAccessFile.lock(); + await randomAccessFile.setPosition(0); + await randomAccessFile.writeFrom(buffer); + await randomAccessFile.truncate(buffer.length); + await randomAccessFile.unlock(); + } finally { + await randomAccessFile.close(); + } + } on FileSystemException catch (e) { + _log.warning('FileSystemException writing file $path: $e'); + return; + } catch (e) { + _log.warning('Error writing file $path: $e'); + return; + } + final key = path.replaceAll(lastPathComponentRegEx, ''); + // ignore: close_sinks + final storage = _storageCache.putIfAbsent(key, () => _newStream(key)); + storage.add(data); + }); + + Future _deleteFile(String path) async { + final dbDir = await Localstore.instance.databaseDirectory; + final file = File(_resolvePath(dbDir.path, path)); + if (await file.exists()) { + try { + await file.delete(); + } catch (e) { + _log.finest(e); + } + } + _fileCache.remove(path); + } + + Future _deleteDirectory(String path) async { + final dbDir = await Localstore.instance.databaseDirectory; + final dir = Directory(_resolvePath(dbDir.path, path)); + _fileCache.removeWhere((key, value) => key.startsWith(path)); + if (await dir.exists()) { + try { + await dir.delete(recursive: true); + } catch (e) { + _log.finest(e); + } + } + _fileCache.removeWhere((key, value) => key.startsWith(path)); + } +} diff --git a/third_party/background_downloader/lib/src/localstore/utils/utils_impl.dart b/third_party/background_downloader/lib/src/localstore/utils/utils_impl.dart new file mode 100644 index 0000000..81dec0c --- /dev/null +++ b/third_party/background_downloader/lib/src/localstore/utils/utils_impl.dart @@ -0,0 +1,11 @@ +abstract class UtilsImpl { + void clearCache(); + Future?> get( + String path, [ + bool? isCollection = false, + List? conditions, + ]); + Future? set(Map data, String path); + Future delete(String path); + Stream> stream(String path, [List? conditions]); +} diff --git a/third_party/background_downloader/lib/src/models.dart b/third_party/background_downloader/lib/src/models.dart new file mode 100644 index 0000000..60bd513 --- /dev/null +++ b/third_party/background_downloader/lib/src/models.dart @@ -0,0 +1,665 @@ +import 'dart:convert'; + +import 'exceptions.dart'; +import 'task.dart'; + +export 'options/mtls.dart'; + +/// Defines a set of possible states which a [Task] can be in. +enum TaskStatus { + /// Task is enqueued on the native platform and waiting to start + /// + /// It may wait for resources, or for an appropriate network to become + /// available before starting the actual download and changing state to + /// `running`. + enqueued, + + /// Task is running, i.e. actively downloading + running, + + /// Task has completed successfully + /// + /// This is a final state + complete, + + /// Task has completed because the url was not found (Http status code 404) + /// + /// This is a final state + notFound, + + /// Task has failed due to an exception + /// + /// This is a final state + failed, + + /// Task has been canceled by the user or the system + /// + /// This is a final state + canceled, + + /// Task failed, and is now waiting to retry + /// + /// The task is held in this state until the exponential backoff time for + /// this retry has passed, and will then be rescheduled on the native + /// platform, switching state to `enqueued` and then `running` + waitingToRetry, + + /// Task is in paused state and may be able to resume + /// + /// To resume a paused Task, call [FileDownloader.resume]. If the resume is + /// possible, status will change to [TaskStatus.running] and continue from + /// there. If resume fails (e.g. because the temp file with the partial + /// download has been deleted by the operating system) status will switch + /// to [TaskStatus.failed] + paused; + + /// True if this state is one of the 'final' states, meaning no more + /// state changes are possible + bool get isFinalState => switch (this) { + .complete || .notFound || .failed || .canceled => true, + .enqueued || .running || .waitingToRetry || .paused => false, + }; + + /// True if this state is not a 'final' state, meaning more + /// state changes are possible + bool get isNotFinalState => !isFinalState; +} + +/// Base directory in which files will be stored, based on their relative +/// path. +/// +/// These correspond to the directories provided by the path_provider package +enum BaseDirectory { + /// As returned by getApplicationDocumentsDirectory() + applicationDocuments, + + /// As returned by getTemporaryDirectory() + temporary, + + /// As returned by getApplicationSupportDirectory() + applicationSupport, + + /// As returned by getApplicationLibrary() on iOS. For other platforms + /// this resolves to the subdirectory 'Library' created in the directory + /// returned by getApplicationSupportDirectory() + applicationLibrary, + + /// System root directory. This allows you to set a path to any directory + /// via [Task.directory]. Only use this if you are certain that this + /// path is stable. on iOS and Android, references to paths within + /// the application's directory structure are *not* stable, and you + /// should use [applicationDocuments], [applicationSupport] or + /// [applicationLibrary] instead to avoid errors. + root, +} + +/// Type of updates requested for a task or group of tasks +enum Updates { + /// no status change or progress updates + none, + + /// only status changes + status, + + /// only progress updates while downloading, no status change updates + progress, + + /// Status change updates and progress updates while downloading + statusAndProgress, +} + +/// Signature for a function you can register to be called +/// when the status of a [Task] changes. +typedef TaskStatusCallback = void Function(TaskStatusUpdate update); + +/// Signature for a function you can register to be called +/// for every progress change of a [Task]. +/// +/// A successfully completed task will always finish with progress 1.0 +/// [TaskStatus.failed] results in progress -1.0 +/// [TaskStatus.canceled] results in progress -2.0 +/// [TaskStatus.notFound] results in progress -3.0 +/// [TaskStatus.waitingToRetry] results in progress -4.0 +/// These constants are available as [progressFailed] etc +typedef TaskProgressCallback = void Function(TaskProgressUpdate update); + +/// Signature for function you can register to be called when a notification +/// is tapped by the user +typedef TaskNotificationTapCallback = void Function( + Task task, + NotificationType notificationType, +); + +/// Signature for a function you can provide to the [FileDownloader.downloadBatch] or +/// [FileDownloader.uploadBatch] that will be called upon completion of each task +/// in the batch. +/// +/// [succeeded] will count the number of successful downloads, and +/// [failed] counts the number of failed downloads (for any reason). +typedef BatchProgressCallback = void Function(int succeeded, int failed); + +/// Contains tasks and results related to a batch of tasks +class Batch( + final List tasks, + final BatchProgressCallback? batchProgressCallback, +) { + final results = {}; + + /// Returns an Iterable with successful tasks in this batch + Iterable get succeeded => results.entries + .where((entry) => entry.value == TaskStatus.complete) + .map((e) => e.key); + + /// Returns the number of successful tasks in this batch + int get numSucceeded => + results.values.where((result) => result == TaskStatus.complete).length; + + /// Returns an Iterable with failed tasks in this batch + Iterable get failed => results.entries + .where((entry) => entry.value != TaskStatus.complete) + .map((e) => e.key); + + /// Returns the number of failed downloads in this batch + int get numFailed => results.values.length - numSucceeded; +} + +/// Base class for updates related to [task]. Actual updates are +/// either a status update or a progress update. +/// +/// When receiving an update, test if the update is a +/// [TaskStatusUpdate] or a [TaskProgressUpdate] +/// and treat the update accordingly +sealed class TaskUpdate { + final Task task; + + const TaskUpdate(this.task); + + /// Create object from [json] + TaskUpdate.fromJson(Map json) + : task = Task.createFromJson(json['task'] ?? json); + + /// Return JSON Map representing object + Map toJson() => {'task': task.toJson()}; +} + +/// A status update +/// +/// Contains [TaskStatus] and, if [TaskStatus.failed] possibly a +/// [TaskException] and if this is a final state possibly [responseBody], +/// [responseHeaders], [responseStatusCode], [mimeType] and [charSet]. +/// Note: header names in [responseHeaders] are converted to lowercase +class TaskStatusUpdate extends TaskUpdate { + final TaskStatus status; // note: serialized as 'taskStatus' + final TaskException? exception; + final String? responseBody; + final int? responseStatusCode; + final Map? responseHeaders; + final String? mimeType; // derived from Content-Type header + final String? charSet; // derived from Content-Type header + + const TaskStatusUpdate( + super.task, + this.status, [ + this.exception, + this.responseBody, + this.responseHeaders, + this.responseStatusCode, + this.mimeType, + this.charSet, + ]); + + /// Create object from [json] + TaskStatusUpdate.fromJson(super.json) + : status = TaskStatus.values[(json['taskStatus'] as num?)?.toInt() ?? 0], + exception = json['exception'] != null + ? TaskException.fromJson(json['exception']) + : null, + responseBody = json['responseBody'], + responseHeaders = json['responseHeaders'] != null + ? Map.from(json['responseHeaders']) + : null, + responseStatusCode = (json['responseStatusCode'] as num?)?.toInt(), + mimeType = json['mimeType'], + charSet = json['charSet'], + super.fromJson(); + + /// Create object from [jsonString] + factory TaskStatusUpdate.fromJsonString(String jsonString) => + TaskStatusUpdate.fromJson(jsonDecode(jsonString)); + + /// Return JSON Map representing object + @override + Map toJson() => { + ...super.toJson(), + 'taskStatus': status.index, + 'exception': exception?.toJson(), + 'responseBody': responseBody, + 'responseHeaders': responseHeaders, + 'responseStatusCode': responseStatusCode, + 'mimeType': mimeType, + 'charSet': charSet, + }; + + TaskStatusUpdate copyWith({ + Task? task, + TaskStatus? status, + TaskException? exception, + String? responseBody, + Map? responseHeaders, + int? responseStatusCode, + String? mimeType, + String? charSet, + }) => TaskStatusUpdate( + task ?? this.task, + status ?? this.status, + exception ?? this.exception, + responseBody ?? this.responseBody, + responseHeaders ?? this.responseHeaders, + responseStatusCode ?? this.responseStatusCode, + mimeType ?? this.mimeType, + charSet ?? this.charSet, + ); +} + +/// A progress update +/// +/// A successfully downloaded task will always finish with progress 1.0 +/// +/// [TaskStatus.failed] results in progress -1.0 +/// [TaskStatus.canceled] results in progress -2.0 +/// [TaskStatus.notFound] results in progress -3.0 +/// [TaskStatus.waitingToRetry] results in progress -4.0 +/// +/// [expectedFileSize] will only be representative if the 0 < [progress] < 1, +/// so NOT representative when progress == 0 or progress == 1, and +/// will be -1 if the file size is not provided by the server or otherwise +/// not known. +/// [networkSpeed] is valid if positive, expressed in MB/second +/// [timeRemaining] is valid if positive +/// +/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining] +/// getters to determine whether a field is valid +class TaskProgressUpdate extends TaskUpdate { + final double progress; + final int expectedFileSize; + final double networkSpeed; // in MB/s + final Duration timeRemaining; + + const TaskProgressUpdate( + super.task, + this.progress, [ + this.expectedFileSize = -1, + this.networkSpeed = -1, + this.timeRemaining = const Duration(seconds: -1), + ]); + + /// Create object from [json] + TaskProgressUpdate.fromJson(super.json) + : progress = (json['progress'] as num?)?.toDouble() ?? progressFailed, + expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1, + networkSpeed = (json['networkSpeed'] as num?)?.toDouble() ?? -1, + timeRemaining = Duration( + seconds: (json['timeRemaining'] as num?)?.toInt() ?? -1, + ), + super.fromJson(); + + /// Create object from [jsonString] + factory TaskProgressUpdate.fromJsonString(String jsonString) => + TaskProgressUpdate.fromJson(jsonDecode(jsonString)); + + /// Return JSON Map representing object + @override + Map toJson() => { + ...super.toJson(), + 'progress': progress, + 'expectedFileSize': expectedFileSize, + 'networkSpeed': networkSpeed, + 'timeRemaining': timeRemaining.inSeconds, + }; + + /// If true, [expectedFileSize] contains a valid value + bool get hasExpectedFileSize => expectedFileSize >= 0; + + /// If true, [networkSpeed] contains a valid value + bool get hasNetworkSpeed => networkSpeed >= 0; + + /// If true, [timeRemaining] contains a valid value + bool get hasTimeRemaining => !timeRemaining.isNegative; + + /// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining] + /// getters to determine whether a field is valid + /// + /// String is '-- MB/s' if N/A, otherwise in MB/s or kB/s + String get networkSpeedAsString => switch (networkSpeed) { + <= 0 => '-- MB/s', + >= 1 => '${networkSpeed.round()} MB/s', + _ => '${(networkSpeed * 1000).round()} kB/s', + }; + + /// String is '--:--' if N/A, otherwise HH:MM:SS or MM:SS + String get timeRemainingAsString => switch (timeRemaining.inSeconds) { + <= 0 => '--:--', + < 3600 => + '${timeRemaining.inMinutes.toString().padLeft(2, "0")}' + ':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', + _ => + '${timeRemaining.inHours}' + ':${timeRemaining.inMinutes.remainder(60).toString().padLeft(2, "0")}' + ':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}', + }; + + @override + String toString() => + 'TaskProgressUpdate{progress: $progress, expectedFileSize: $expectedFileSize, networkSpeed: $networkSpeed, timeRemaining: $timeRemaining}'; +} + +// Progress values representing a status +const progressRunning = 0.0; +const progressComplete = 1.0; +const progressFailed = -1.0; +const progressCanceled = -2.0; +const progressNotFound = -3.0; +const progressWaitingToRetry = -4.0; +const progressPaused = -5.0; + +/// Holds data associated with a resume +class ResumeData { + final Task task; + final String data; + final int requiredStartByte; + final String? eTag; + + const ResumeData( + this.task, + this.data, [ + this.requiredStartByte = 0, + this.eTag, + ]); + + /// Create object from [json] + ResumeData.fromJson(Map json) + : task = Task.createFromJson(json['task']), + data = json['data'] as String, + requiredStartByte = (json['requiredStartByte'] as num?)?.toInt() ?? 0, + eTag = json['eTag'] as String?; + + /// Create object from [jsonString] + factory ResumeData.fromJsonString(String jsonString) => + ResumeData.fromJson(jsonDecode(jsonString)); + + /// Return JSON Map representing object + Map toJson() => { + 'task': task.toJson(), + 'data': data, + 'requiredStartByte': requiredStartByte, + 'eTag': eTag, + }; + + String get taskId => task.taskId; + + /// The tempFilepath contained in the [data] field + String get tempFilepath => data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResumeData && + runtimeType == other.runtimeType && + task == other.task && + data == other.data && + requiredStartByte == other.requiredStartByte && + eTag == other.eTag; + + @override + int get hashCode => + task.hashCode ^ + data.hashCode ^ + requiredStartByte.hashCode ^ + (eTag?.hashCode ?? 0); +} + +/// Types of undelivered data that can be requested +enum Undelivered { + /// Resume data + resumeData, + + /// Status updates + statusUpdates, + + /// Progress updates + progressUpdates, +} + +/// Notification types, as configured in [TaskNotificationConfig] and passed +/// on to [TaskNotificationTapCallback] +enum NotificationType { + /// Task is running + running, + + /// Task has completed + complete, + + /// Task has failed + error, + + /// Task is paused + paused, + + /// Task is canceled + canceled, +} + +/// Notification specification for a [Task] +/// +/// [body] and [title] may contain special strings to substitute display values: +/// {filename] to insert the filename +/// {progress} to insert progress in % +/// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A +/// {timeRemaining} to insert the estimated time remaining to complete the task +/// in HH:MM:SS or MM:SS or --:-- if N/A +/// +/// Actual appearance of notification is dependent on the platform, e.g. +/// on iOS {progress} is not available and ignored +final class const TaskNotification(final String title, final String body) { + /// Return JSON Map representing object + Map toJson() => {"title": title, "body": body}; +} + +/// Notification configuration object +/// +/// Determines how a [taskOrGroup] or [Task.group] of tasks needs to be notified +/// +/// [running] is the notification used while the task is in progress +/// [complete] is the notification used when the task completed +/// [error] is the notification used when something went wrong, +/// including pause, failed and notFound status +/// [progressBar] if set will show a progress bar +/// [tapOpensFile] if set will attempt to open the file when the [complete] +/// notification is tapped +/// [groupNotificationId] if set will group all notifications with the same +/// [groupNotificationId] and change the progress bar to number of finished +/// tasks versus total number of tasks in the groupNotification. +/// Use {finished} and {total} tokens in the [TaskNotification.title] and +/// [TaskNotification.body] to substitute. Task-specific substitutions +/// such as {filename} are not valid. +/// The groupNotification is considered [complete] when there are no +/// more tasks running within that group, and at that point the +/// [complete] notification is shown (if configured). If any task in the +/// groupNotification fails, the [error] notification is shown. +/// The first character of the [groupNotificationId] cannot be '*'. +final class TaskNotificationConfig { + final dynamic taskOrGroup; + final TaskNotification? running; + final TaskNotification? complete; + final TaskNotification? error; + final TaskNotification? paused; + final TaskNotification? canceled; + final bool progressBar; + final bool tapOpensFile; + final String groupNotificationId; + + /// Create notification configuration that determines what notifications are shown, + /// whether a progress bar is shown (Android only), and whether tapping + /// the 'complete' notification opens the downloaded file. + /// + /// [running] is the notification used while the task is in progress + /// [complete] is the notification used when the task completed + /// [error] is the notification used when something went wrong, + /// including failed and notFound status + /// [paused] is the notification shown when the task is paused + /// [canceled] is the notification shown when the task is canceled programmatically + /// or by the user via a notification button + /// [progressBar] if set will show a progress bar + /// [tapOpensFile] if set will attempt to open the file when the [complete] + /// notification is tapped + /// [groupNotificationId] if set will group all notifications with the same + /// [groupNotificationId] and change the progress bar to number of finished + /// tasks versus total number of tasks in the groupNotification. + /// Use {numFinished}, {numFailed} and {numTotal} tokens in the [TaskNotification.title] + /// and [TaskNotification.body] to substitute. Task-specific substitutions + /// such as {filename} are not valid. + /// The groupNotification is considered [complete] when there are no + /// more tasks running within that group, and at that point the + /// [complete] notification is shown (if configured). If any task in the + /// groupNotification fails, the [error] notification is shown. + /// The first character of the [groupNotificationId] cannot be '*'. + TaskNotificationConfig({ + this.taskOrGroup, + this.running, + this.complete, + this.error, + this.paused, + this.canceled, + this.progressBar = false, + this.tapOpensFile = false, + this.groupNotificationId = '', + }) { + assert( + running != null || + complete != null || + error != null || + paused != null || + canceled != null, + 'At least one notification must be set', + ); + } + + /// Return JSON Map representing object, excluding the [taskOrGroup] field, + /// as the JSON map is only required to pass along the config with a task + Map toJson() => { + 'running': running?.toJson(), + 'complete': complete?.toJson(), + 'error': error?.toJson(), + 'paused': paused?.toJson(), + 'canceled': canceled?.toJson(), + 'progressBar': progressBar, + 'tapOpensFile': tapOpensFile, + 'groupNotificationId': groupNotificationId, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TaskNotificationConfig && + runtimeType == other.runtimeType && + taskOrGroup == other.taskOrGroup; + + @override + int get hashCode => taskOrGroup.hashCode; +} + +/// Shared storage destinations +enum SharedStorage { + /// The 'Downloads' directory + downloads, + + /// The 'Photos' or 'Images' or 'Pictures' directory + images, + + /// The 'Videos' or 'Movies' directory + video, + + /// The 'Music' or 'Audio' directory + audio, + + /// Android-only: the 'Files' directory + files, + + /// Android-only: the 'external storage' directory + external, +} + +final class Config { + // Config topics + + /// Config string for request timeout + static const requestTimeout = 'requestTimeout'; + + /// Config string for resource timeout + static const resourceTimeout = 'resourceTimeout'; + + /// Config string for check available space + static const checkAvailableSpace = 'checkAvailableSpace'; + + /// Config string for proxy + static const proxy = 'proxy'; + + /// Config string for bypass TLS certificate validation + static const bypassTLSCertificateValidation = + 'bypassTLSCertificateValidation'; + + /// Config string for mutual TLS (mTLS) + static const mTLS = 'mTLS'; + + /// Config string for run in foreground + static const runInForeground = 'runInForeground'; + + /// Config string for run in foreground if file larger than + static const runInForegroundIfFileLargerThan = + 'runInForegroundIfFileLargerThan'; + + /// Config string for localize + static const localize = 'localize'; + + /// Config string for exclude from cloud backup + static const excludeFromCloudBackup = 'excludeFromCloudBackup'; + + /// Config string for use cache dir + static const useCacheDir = 'useCacheDir'; + + /// Config string for use external storage + static const useExternalStorage = 'useExternalStorage'; + + /// Config string for holding queue + static const holdingQueue = 'holdingQueue'; + + /// Config string for skip existing files + static const skipExistingFiles = 'skipExistingFiles'; + + /// Config string for temp file path + static const tempFilePath = 'tempFilePath'; + + // Config arguments + + /// Config argument for always + static const always = 'always'; // int 0 on native side + + /// Config argument for never + static const never = 'never'; // int -1 on native side + + /// Config argument for when able + static const whenAble = 'whenAble'; // int -2 on native side + + /// Returns the int equivalent of commonly used String arguments + /// + /// The int equivalent is used in communication with the native downloader + static int argToInt(String argument) => switch (argument) { + Config.always => 0, + Config.whenAble => -2, + Config.never => -1, + _ => throw ArgumentError('Argument $argument cannot be converted to int'), + }; +} + +/// Wifi requirement modes at the application level +enum RequireWiFi { asSetByTask, forAllTasks, forNoTasks } diff --git a/third_party/background_downloader/lib/src/native_downloader.dart b/third_party/background_downloader/lib/src/native_downloader.dart new file mode 100644 index 0000000..8579985 --- /dev/null +++ b/third_party/background_downloader/lib/src/native_downloader.dart @@ -0,0 +1,886 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'base_downloader.dart'; +import 'chunk.dart'; +import 'exceptions.dart'; +import 'file_downloader.dart'; +import 'models.dart'; +import 'json_processor.dart'; +import 'permissions.dart'; +import 'queue/serial_job_queue.dart'; +import 'task.dart'; + +/// Implementation of download functionality for native platforms +/// +/// Uses [MethodChannel] to communicate with native platforms +abstract base class NativeDownloader extends BaseDownloader { + static const methodChannel = MethodChannel( + 'com.bbflight.background_downloader', + ); + static const _backgroundChannel = MethodChannel( + 'com.bbflight.background_downloader.background', + ); + + late final SerialJobQueue _jobQueue; + + /// Initializes the background channel and starts listening for messages from + /// the native side + @override + Future initialize() async { + await super.initialize(); + WidgetsFlutterBinding.ensureInitialized(); + // listen to the background channel, receiving updates on download status + // or progress. + // The job queue ensures that messages are processed in order, even though + // the processing itself is asynchronous (using [compute]) + _jobQueue = SerialJobQueue(_handleBackgroundMessage); + _backgroundChannel.setMethodCallHandler(_jobQueue.add); + } + + /// Handles the background message + /// + /// First argument is the Task as JSON string, next argument(s) depends + /// on the method. + /// + /// If the task JsonString is empty, a dummy task will be created + Future _handleBackgroundMessage(MethodCall call) async { + final args = call.arguments as List; + final taskJsonString = args.first as String; + final task = taskJsonString.isNotEmpty + ? await JsonProcessor().decodeTask(taskJsonString) + : DownloadTask(url: 'url'); + final message = ( + call.method, + args.length > 2 + ? args.getRange(1, args.length).toList(growable: false) + : args[1], + ); + switch (message) { + // simple status update + case ('statusUpdate', final int statusOrdinal): + final status = TaskStatus.values[statusOrdinal]; + if (task.group != BaseDownloader.chunkGroup) { + processStatusUpdate(TaskStatusUpdate(task, status)); + } else { + // this is a chunk task, so pass to native + Future.delayed(const Duration(milliseconds: 100)).then( + (_) => methodChannel.invokeMethod('chunkStatusUpdate', [ + Chunk.getParentTaskId(task), + task.taskId, + status.index, + null, + null, + ]), + ); + } + + // status update with responseBody, responseHeaders, responseStatusCode, mimeType and charSet (normal completion) + case ( + 'statusUpdate', + [ + final int statusOrdinal, + final String? responseBody, + final Map? responseHeaders, + final int? responseStatusCode, + final String? mimeType, + final String? charSet, + ], + ): + final status = TaskStatus.values[statusOrdinal]; + if (task.group != BaseDownloader.chunkGroup) { + final Map? cleanResponseHeaders = + responseHeaders == null + ? null + : { + for (final entry in responseHeaders.entries.where( + (entry) => entry.key != null && entry.value != null, + )) + entry.key.toString().toLowerCase(): entry.value.toString(), + }; + processStatusUpdate( + TaskStatusUpdate( + task, + status, + null, + responseBody, + cleanResponseHeaders, + responseStatusCode, + mimeType, + charSet, + ), + ); + } else { + // this is a chunk task, so pass to native + Future.delayed(const Duration(milliseconds: 100)).then( + (_) => methodChannel.invokeMethod('chunkStatusUpdate', [ + Chunk.getParentTaskId(task), + task.taskId, + status.index, + null, + responseBody, + ]), + ); + } + + // status update with TaskException and responseBody + case ( + 'statusUpdate', + [ + final int statusOrdinal, + final String typeString, + final String description, + final int httpResponseCode, + final String? responseBody, + ], + ): + final status = TaskStatus.values[statusOrdinal]; + TaskException? exception; + if (status == TaskStatus.failed) { + exception = TaskException.fromTypeString( + typeString, + description, + httpResponseCode, + ); + } + if (task.group != BaseDownloader.chunkGroup) { + processStatusUpdate( + TaskStatusUpdate(task, status, exception, responseBody), + ); + } else { + // this is a chunk task, so pass to native + Future.delayed(const Duration(milliseconds: 100)).then( + (_) => methodChannel.invokeMethod('chunkStatusUpdate', [ + Chunk.getParentTaskId(task), + task.taskId, + status.index, + exception?.toJsonString(), + responseBody, + ]), + ); + } + + case ( + 'progressUpdate', + [ + final double progress, + final int expectedFileSize, + final double networkSpeed, + final int timeRemaining, + ], + ): + if (task.group != BaseDownloader.chunkGroup) { + processProgressUpdate( + TaskProgressUpdate( + task, + progress, + expectedFileSize, + networkSpeed, + Duration(milliseconds: timeRemaining), + ), + ); + } else { + // this is a chunk task, so pass parent taskId, + // chunk taskId and progress to native + Future.delayed(const Duration(milliseconds: 100)).then( + (_) => methodChannel.invokeMethod('chunkProgressUpdate', [ + Chunk.getParentTaskId(task), + task.taskId, + progress, + ]), + ); + } + + case ('canResume', final bool canResume): + setCanResume(task, canResume); + + // resumeData Android and Desktop variant + case ( + 'resumeData', + [final String data, final int requiredStartByte, final String? eTag], + ): + setResumeData(ResumeData(task, data, requiredStartByte, eTag)); + + // resumeData iOS and ParallelDownloads variant + case ('resumeData', final String data): + setResumeData(ResumeData(task, data)); + + case ('notificationTap', final int notificationTypeOrdinal): + final notificationType = + NotificationType.values[notificationTypeOrdinal]; + processNotificationTap(task, notificationType); + return true; // this message requires a confirmation + + // from ParallelDownloadTask + case ('enqueueChild', final String childTaskJsonString): + final childTask = await JsonProcessor().decodeTask(childTaskJsonString); + // The parent treats this acknowledgement as proof that a child exists. + // Returning before native enqueue finishes strands the parent forever + // when WorkManager rejects that child. Return the real result instead. + try { + return await FileDownloader().enqueue(childTask); + } catch (error) { + log.warning('Native child enqueue failed', error); + return false; + } + + // from ParallelDownloadTask + case ('cancelTasksWithId', final String listOfTaskIdsJson): + final taskIds = List.from(jsonDecode(listOfTaskIdsJson)); + Future.delayed(const Duration(milliseconds: 100)) + .then((_) => FileDownloader().cancelTasksWithIds(taskIds)); + + // from ParallelDownloadTask + case ('pauseTasks', final String listOfTasksJson): + final listOfTasks = await JsonProcessor().decodeDownloadTaskList( + listOfTasksJson, + ); + Future.delayed(const Duration(milliseconds: 100)).then((_) async { + for (final chunkTask in listOfTasks) { + await FileDownloader().pause(chunkTask); + } + }); + + // for permission request results + case ('permissionRequestResult', final int statusOrdinal): + permissionsService.onPermissionRequestResult( + PermissionStatus.values[statusOrdinal], + ); + + default: + log.warning('Background channel: no match for message $message'); + throw ArgumentError( + 'Background channel: no match for message $message', + ); + } + await flushDatabaseUpdates(); + return true; + } + + @override + Future enqueue(Task task) async { + super.enqueue(task); + final notificationConfig = notificationConfigForTask(task); + return await methodChannel.invokeMethod('enqueue', [ + jsonEncode(task.toJson()), + if (notificationConfig != null) + jsonEncode(notificationConfig.toJson()) + else + null, + ]) ?? + false; + } + + @override + Future> enqueueAll(Iterable tasks) async { + for (final task in tasks.where((task) => task.allowPause)) { + canResumeTask[task] = Completer(); + } + tasks.forEach(registerTaskNotificationConfig); + final ( + String tasksJsonString, + String notificationConfigsJsonString, + ) = await JsonProcessor().encodeTaskAndNotificationConfig( + tasks, + notificationConfigs, + ); + final result = + await methodChannel.invokeMethod>('enqueueAll', [ + tasksJsonString, + notificationConfigsJsonString, + ]) ?? + []; + return result.map((item) => item is bool ? item : false).toList(); + } + + @override + Future reset(String group) async { + final retryAndPausedTaskCount = await super.reset(group); + final nativeCount = + await methodChannel.invokeMethod('reset', group) ?? 0; + return retryAndPausedTaskCount + nativeCount; + } + + @override + Future> allTasks( + String group, + bool includeTasksWaitingToRetry, + allGroups, + ) async { + final retryAndPausedTasks = await super.allTasks( + group, + includeTasksWaitingToRetry, + allGroups, + ); + final result = + await methodChannel.invokeMethod?>( + 'allTasks', + allGroups ? null : group, + ) ?? + []; + final tasks = await JsonProcessor().decodeTaskList(result); + return [...retryAndPausedTasks, ...tasks]; + } + + @override + Future cancelPlatformTasksWithIds(List taskIds) async => + await methodChannel.invokeMethod('cancelTasksWithIds', taskIds) ?? + false; + + @override + Future taskForId(String taskId) async { + final task = await super.taskForId(taskId); + if (task != null) { + return task; + } + final jsonString = await methodChannel.invokeMethod( + 'taskForId', + taskId, + ); + if (jsonString != null) { + return Task.createFromJsonString(jsonString); + } + return null; + } + + @override + Future pause(Task task) async => + await methodChannel.invokeMethod('pause', task.taskId) ?? false; + + @override + Future> pauseTaskList(Iterable tasksToPause) async { + final taskIds = tasksToPause + .map((task) => task.taskId) + .toList(growable: false); + final results = await methodChannel.invokeMethod>( + 'pauseAll', + taskIds, + ); + return results?.cast() ?? tasksToPause.map((task) => false).toList(); + } + + @override + Future resume(Task task) async { + if (await super.resume(task)) { + task = awaitTasks.containsKey(task) + ? awaitTasks.keys.firstWhere( + (awaitTask) => awaitTask.taskId == task.taskId, + ) + : task; + final taskResumeData = await getResumeData(task.taskId); + if (taskResumeData != null) { + final notificationConfig = notificationConfigForTask(task); + final enqueueSuccess = + await methodChannel.invokeMethod('enqueue', [ + jsonEncode(task.toJson()), + if (notificationConfig != null) + jsonEncode(notificationConfig.toJson()) + else + null, + taskResumeData.data, + taskResumeData.requiredStartByte, + taskResumeData.eTag, + ]) ?? + false; + if (enqueueSuccess && task is ParallelDownloadTask) { + return resumeChunkTasks(task, taskResumeData); + } + return enqueueSuccess; + } + } + return false; + } + + @override + Future requireWiFi( + RequireWiFi requirement, + rescheduleRunningTasks, + alsoRestartUploads, + ) async => + await methodChannel.invokeMethod('requireWiFi', [ + requirement.index, + rescheduleRunningTasks, + alsoRestartUploads, + ]) ?? + false; + + @override + Future getRequireWiFiSetting() async => RequireWiFi + .values[await methodChannel.invokeMethod('getRequireWiFiSetting') ?? 0]; + + @override + void updateNotification(Task task, TaskStatus? taskStatusOrNull) { + final notificationConfig = notificationConfigForTask(task); + if (notificationConfig != null) { + methodChannel.invokeMethod('updateNotification', [ + jsonEncode(task.toJson()), + jsonEncode(notificationConfig.toJson()), + taskStatusOrNull?.index, + ]); + } + } + + /// Retrieve data that was not delivered to Dart, as a Map keyed by taskId + /// with one map for each taskId + /// + /// Asks the native platform for locally stored data for resumeData, + /// status updates or progress updates. + /// ResumeData has a [ResumeData] json representation + /// StatusUpdates has a mixed Task & TaskStatus json representation 'taskStatus' + /// ProgressUpdates has a mixed Task & double json representation 'progress' + @override + Future> popUndeliveredData(Undelivered dataType) async { + final String jsonString = await switch (dataType) { + Undelivered.resumeData => methodChannel.invokeMethod('popResumeData'), + Undelivered.statusUpdates => methodChannel.invokeMethod( + 'popStatusUpdates', + ), + Undelivered.progressUpdates => methodChannel.invokeMethod( + 'popProgressUpdates', + ), + }; + return Map.from(jsonDecode(jsonString)); + } + + @override + Future moveToSharedStorage( + String filePath, + SharedStorage destination, + String directory, + String? mimeType, { + bool asUriString = false, + }) => methodChannel.invokeMethod('moveToSharedStorage', [ + filePath, + destination.index, + directory, + mimeType, + asUriString, + ]); + + @override + Future pathInSharedStorage( + String filePath, + SharedStorage destination, + String directory, { + bool asUriString = false, + }) => methodChannel.invokeMethod('pathInSharedStorage', [ + filePath, + destination.index, + directory, + asUriString, + ]); + + @override + Future openFile(Task? task, String? filePath, String? mimeType) async { + final result = await methodChannel.invokeMethod('openFile', [ + if (task != null) jsonEncode(task.toJson()) else null, + filePath, + mimeType, + ]); + return result ?? false; + } + + @override + Future platformVersion() async => + (await methodChannel.invokeMethod('platformVersion')) ?? ''; + + @override + Future getTaskTimeout() async { + if (defaultTargetPlatform == TargetPlatform.android) { + final timeoutMillis = + await methodChannel.invokeMethod('getTaskTimeout') ?? 0; + return Duration(milliseconds: timeoutMillis); + } + return const Duration(hours: 4); // on iOS, resource timeout + } + + @override + Future setForceFailPostOnBackgroundChannel(bool value) async { + await methodChannel.invokeMethod('forceFailPostOnBackgroundChannel', value); + } + + @override + Future testSuggestedFilename( + DownloadTask task, + String contentDisposition, + ) async => + await methodChannel.invokeMethod('testSuggestedFilename', [ + jsonEncode(task.toJson()), + contentDisposition, + ]) ?? + ''; + + @override + Future<(String, String)> configureItem((String, dynamic) configItem) async { + switch (configItem) { + case (Config.requestTimeout, final Duration? duration): + await NativeDownloader.methodChannel.invokeMethod( + 'configRequestTimeout', + duration?.inSeconds, + ); + + case (Config.proxy, (final String address, final int port)): + await NativeDownloader.methodChannel.invokeMethod( + 'configProxyAddress', + address, + ); + await NativeDownloader.methodChannel.invokeMethod( + 'configProxyPort', + port, + ); + + case (Config.proxy, false): + await NativeDownloader.methodChannel.invokeMethod( + 'configProxyAddress', + null, + ); + await NativeDownloader.methodChannel.invokeMethod( + 'configProxyPort', + null, + ); + + case (Config.checkAvailableSpace, final int minimum): + assert(minimum > 0, 'Minimum available space must be in MB and > 0'); + await NativeDownloader.methodChannel.invokeMethod( + 'configCheckAvailableSpace', + minimum, + ); + + case (Config.checkAvailableSpace, false): + case (Config.checkAvailableSpace, Config.never): + await NativeDownloader.methodChannel.invokeMethod( + 'configCheckAvailableSpace', + null, + ); + + case ( + Config.holdingQueue, + ( + final int? maxConcurrent, + final int? maxConcurrentByHost, + final int? maxConcurrentByGroup, + ), + ): + await NativeDownloader.methodChannel.invokeMethod( + 'configHoldingQueue', + [ + maxConcurrent ?? 1 << 20, + maxConcurrentByHost ?? 1 << 20, + maxConcurrentByGroup ?? 1 << 20, + ], + ); + + case (Config.holdingQueue, Config.never): + case (Config.holdingQueue, false): + await NativeDownloader.methodChannel.invokeMethod( + 'configHoldingQueue', + [], + ); + + case (Config.skipExistingFiles, final int value): + await NativeDownloader.methodChannel.invokeMethod( + 'configSkipExistingFiles', + value, + ); + + case (Config.skipExistingFiles, Config.never): + case (Config.skipExistingFiles, false): + await NativeDownloader.methodChannel.invokeMethod( + 'configSkipExistingFiles', + -1, + ); + + case (Config.skipExistingFiles, Config.always): + case (Config.skipExistingFiles, true): + await NativeDownloader.methodChannel.invokeMethod( + 'configSkipExistingFiles', + 0, + ); + + default: + return ( + configItem.$1, + 'not implemented', + ); // this method did not process this configItem + } + return (configItem.$1, ''); // normal result + } +} + +/// Android native downloader +final class AndroidDownloader extends NativeDownloader { + static final AndroidDownloader _singleton = AndroidDownloader._internal(); + static int? _callbackDispatcherRawHandle; + + factory AndroidDownloader() => _singleton; + + AndroidDownloader._internal(); + + @override + Future enqueue(Task task) async { + await _registerCallbackDispatcher(task); + return super.enqueue(task); + } + + @override + Future> enqueueAll(Iterable tasks) async { + for (final task in tasks) { + await _registerCallbackDispatcher(task); + } + return super.enqueueAll(tasks); + } + + /// On Android, need to register [_callbackDispatcherRawHandle] upon first + /// encounter of a task with callbacks + Future _registerCallbackDispatcher(Task task) async { + // on Android, need to register [_callbackDispatcherRawHandle] upon first + // encounter of a task with callbacks + if (task.options?.hasCallback == true && + _callbackDispatcherRawHandle == null) { + final rawHandle = PluginUtilities.getCallbackHandle( + initCallbackDispatcher, + )?.toRawHandle(); + if (rawHandle != null) { + final success = await NativeDownloader.methodChannel.invokeMethod( + 'registerCallbackDispatcher', + rawHandle, + ); + if (success == true) { + _callbackDispatcherRawHandle = rawHandle; + log.fine('Registered callbackDispatcher with handle $rawHandle'); + } else { + log.warning('Could not register callbackDispatcher'); + } + } else { + log.warning('Could not obtain rawHandle for initCallbackDispatcher'); + } + } + } + + @override + dynamic platformConfig({ + dynamic globalConfig, + dynamic androidConfig, + dynamic iOSConfig, + dynamic desktopConfig, + }) => androidConfig; + + @override + Future<(String, String)> configureItem((String, dynamic) configItem) async { + final superResult = await super.configureItem(configItem); + if (superResult.$2 != 'not implemented') { + return superResult; + } + switch (configItem) { + case (Config.runInForeground, final bool activate): + await NativeDownloader.methodChannel.invokeMethod( + 'configForegroundFileSize', + activate ? 0 : -1, + ); + + case (Config.runInForeground, final String whenTo): + assert( + [Config.never, Config.always].contains(whenTo), + '${Config.runInForeground} expects one of ${[Config.never, Config.always]}', + ); + await NativeDownloader.methodChannel.invokeMethod( + 'configForegroundFileSize', + Config.argToInt(whenTo), + ); + + case (Config.runInForegroundIfFileLargerThan, final int fileSize): + await NativeDownloader.methodChannel.invokeMethod( + 'configForegroundFileSize', + fileSize, + ); + + case (Config.bypassTLSCertificateValidation, final bool bypass): + if (bypass) { + if (kReleaseMode) { + throw ArgumentError( + 'You cannot bypass certificate validation in release mode', + ); + } + await NativeDownloader.methodChannel.invokeMethod( + 'configBypassTLSCertificateValidation', + ); + log.warning( + 'TLS certificate validation is bypassed. This is insecure and cannot be ' + 'done in release mode', + ); + } else { + throw ArgumentError( + 'To undo bypassing the certificate validation, ' + 'restart and leave out the "configBypassCertificateValidation" configuration', + ); + } + + case (Config.useCacheDir, final String whenTo): + assert( + [Config.never, Config.whenAble, Config.always].contains(whenTo), + '${Config.useCacheDir} expects one of ${[Config.never, Config.whenAble, Config.always]}', + ); + await NativeDownloader.methodChannel.invokeMethod( + 'configUseCacheDir', + Config.argToInt(whenTo), + ); + + case (Config.useExternalStorage, final String whenTo): + assert( + [Config.never, Config.always].contains(whenTo), + '${Config.useExternalStorage} expects one of ${[Config.never, Config.always]}', + ); + await NativeDownloader.methodChannel.invokeMethod( + 'configUseExternalStorage', + Config.argToInt(whenTo), + ); + Task.useExternalStorage = whenTo == Config.always; + + case (Config.tempFilePath, final String path): + final cleanPath = (path.isNotEmpty && path != Config.never) + ? path + : null; + await NativeDownloader.methodChannel.invokeMethod( + 'configTempFilePath', + cleanPath, + ); + + case (Config.tempFilePath, null): + case (Config.tempFilePath, false): + await NativeDownloader.methodChannel.invokeMethod( + 'configTempFilePath', + null, + ); + + default: + return ( + configItem.$1, + 'not implemented', + ); // this method did not process this configItem + } + return (configItem.$1, ''); // normal result + } +} + +/// iOS native downloader +final class IOSDownloader extends NativeDownloader { + static final IOSDownloader _singleton = IOSDownloader._internal(); + + factory IOSDownloader() => _singleton; + + IOSDownloader._internal(); + + /// On iOS we immediately initialize the callback dispatcher and start + /// listening for callback messages from the native side + /// + /// Whereas on Android, the initialization is done directly by the native side + /// using a DartExecutor + @override + Future initialize() async { + initCallbackDispatcher(); + return super.initialize(); + } + + @override + dynamic platformConfig({ + dynamic globalConfig, + dynamic androidConfig, + dynamic iOSConfig, + dynamic desktopConfig, + }) => iOSConfig; + + @override + Future<(String, String)> configureItem((String, dynamic) configItem) async { + final superResult = await super.configureItem(configItem); + if (superResult.$2 != 'not implemented') { + return superResult; + } + switch (configItem) { + case (Config.resourceTimeout, final Duration? duration): + await NativeDownloader.methodChannel.invokeMethod( + 'configResourceTimeout', + duration?.inSeconds, + ); + + case (Config.localize, final Map? translation): + await NativeDownloader.methodChannel.invokeMethod( + 'configLocalize', + translation, + ); + + case (Config.excludeFromCloudBackup, final dynamic exclude): + assert( + exclude is bool || [Config.always, Config.never].contains(exclude), + '${Config.excludeFromCloudBackup} expects one of ${['true', 'false', Config.never, Config.always]}', + ); + final boolValue = (exclude == true || exclude == Config.always); + await NativeDownloader.methodChannel.invokeMethod( + 'configExcludeFromCloudBackup', + boolValue, + ); + + default: + return ( + configItem.$1, + 'not implemented', + ); // this method did not process this configItem + } + return (configItem.$1, ''); // normal result + } +} + +const _callbackChannel = MethodChannel( + 'com.bbflight.background_downloader.callbacks', +); + +/// Initialize the callbackDispatcher for native callbacks +/// +/// Establishes the methodChannel through which the native side will send its +/// callBacks, and the listener that processes the different callback types. +/// +/// This method is called directly from the native platform prior to using +/// the [_callbackChannel] to post the actual callback +@pragma('vm:entry-point') +void initCallbackDispatcher() { + WidgetsFlutterBinding.ensureInitialized(); + _callbackChannel.setMethodCallHandler((MethodCall call) async { + switch (call.method) { + case 'beforeTaskStartCallback': + final taskJsonString = call.arguments as String; + final task = Task.createFromJson(jsonDecode(taskJsonString)); + final callBack = task.options?.beforeTaskStartCallBack; + final taskStatusUpdate = await callBack?.call(task); + if (taskStatusUpdate == null) { + return null; + } + return jsonEncode(taskStatusUpdate.toJson()); + + case 'onTaskStartCallback': + case 'onAuthCallback': + final taskJsonString = call.arguments as String; + final task = Task.createFromJson(jsonDecode(taskJsonString)); + final callBack = call.method == 'onTaskStartCallback' + ? task.options?.onTaskStartCallBack + : task.options?.auth?.onAuthCallback; + final newTask = await callBack?.call(task); + if (newTask == null) { + return null; + } + return jsonEncode(newTask.toJson()); + + case 'onTaskFinishedCallback': + final taskUpdateJsonString = call.arguments as String; + final taskStatusUpdate = TaskStatusUpdate.fromJsonString( + taskUpdateJsonString, + ); + final callBack = taskStatusUpdate.task.options?.onTaskFinishedCallBack; + await callBack?.call(taskStatusUpdate); + return null; + } + }); +} diff --git a/third_party/background_downloader/lib/src/options/auth.dart b/third_party/background_downloader/lib/src/options/auth.dart new file mode 100644 index 0000000..1b38e06 --- /dev/null +++ b/third_party/background_downloader/lib/src/options/auth.dart @@ -0,0 +1,370 @@ +import 'dart:convert'; +import 'dart:core'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import 'auth_callback.dart'; + +/// Provides an authentication handler for HTTP requests with support for access +/// and refresh tokens, customizable headers, and query parameters. +/// +/// This class manages token-based authentication for HTTP requests, allowing +/// you to configure access and refresh tokens, as well as related headers and +/// query parameters. It supports automatic token refresh if an access token +/// expires and facilitates using a refresh token endpoint. +/// +/// To use, : +/// - Create a [Task] as with a simple url, without query parameters, and +/// no headers. Query parameters and headers will be added based on the +/// [Auth] object, that you add to the task via [Task.options]. Include +/// [TaskOptions] with the [Auth] object as follows: +/// - Create an [Auth] object +/// - Add [accessQueryParams] containing all url query parameters. If your auth +/// uses query parameters for auth, then use `{accessToken}` to indicate where +/// the token should go, eg `{"auth":"{accessToken}"}` will add the access +/// token to the ur query parameter auth +/// - Add [accessHeaders] containing all headers required for the task. If your +/// auth uses headers, then use `{accessToken}` to indicate where the token +/// should go, eg `{"Authentication":" Bearer {accessToken}"}` will add a +/// typical authentication header for OAuth or JWT based requests. +/// - Add information for refresh, eg [refreshToken], [refreshUrl] if you want +/// to use the default handler +/// - If you use your own handler, add that as `onAuth` - otherwise the refresh +/// will be attempted using [defaultOnAuth] +/// +/// For tasks that contain an [Auth] object, the background_downloader will +/// first check if the token is expired. If the token is expired, it will call +/// the `onAuth` callback, which should refresh the token by calling +/// [refreshToken], which will update the [Auth] associated with the task, then +/// return the modified task. +/// The downloader will substitute the `{accessToken}` placeholder with the +/// accessToken in both query parameters and headers before executing the task. +/// +/// ### Key Properties +/// - [accessToken]: The current access token used for authentication. +/// - [refreshToken]: The refresh token used to obtain a new access token. +/// - [accessHeaders]: Headers to include in authenticated requests, with +/// optional `{accessToken}` placeholders for dynamic token insertion. +/// - [refreshHeaders]: Headers to include when making a refresh token request, +/// with option `{refreshToken}` placeholders for dynamic token insertion. +/// - [onAuth]: callback to be called before the task starts if access token is +/// expired. Callback should return a modified Task, or null to try with the +/// original +/// +/// ### Key Methods +/// - [getAccessUri]: Returns the URI with authentication query parameters, +/// automatically refreshing the access token if needed. +/// - [getAccessHeaders]: Returns the headers to be used with the access +/// request, with the `{accessToken}` placeholder replaced +/// - [refreshAccessToken]: Refreshes the access token using the configured +/// refresh token and updates the expiry time. Called by [getAccessUri] +/// - [isTokenExpired]: Checks if the access token is nearing expiry. +/// +/// ### Example +/// ```dart +/// final auth = Auth( +/// accessToken: 'initialAccessToken', +/// refreshToken: 'initialRefreshToken', +/// refreshUrl: 'https://example.com/token/refresh', +/// ); +/// +/// // Make a request with updated headers and query parameters +/// Uri uri = await auth.getAccessUri(url: 'https://example.com/data'); +/// Map headers = auth.getAccessHeaders(); +/// ``` +class Auth { + static final log = Logger('Auth'); + + /// The access token used for authentication + String? accessToken; + + /// Headers to use for access requests. Can contain {accessToken} and + /// {refreshToken} which will be replaced by the actual tokens + Map accessHeaders; + + /// Query parameters to use for access requests. Can contain {accessToken} and + /// {refreshToken} which will be replaced by the actual tokens + Map accessQueryParams; + + /// Time when the access token expires + DateTime? accessTokenExpiryTime; + + /// The refresh token used to obtain a new access token + String? refreshToken; + + /// Headers to use for refresh requests. Can contain {accessToken} and + /// {refreshToken} which will be replaced by the actual tokens + Map refreshHeaders; + + /// Query parameters to use for refresh requests. Can contain {accessToken} and + /// {refreshToken} which will be replaced by the actual tokens + Map refreshQueryParams; + + /// The URL to use for refreshing the token + String? refreshUrl; + int? _onAuthRawHandle; // for callback + + /// Creates an [Auth] object + /// + /// [accessToken] is the initial access token + /// [accessHeaders] are headers to use for access requests + /// [accessQueryParams] are query parameters to use for access requests + /// [accessTokenExpiryTime] is the time when the access token expires + /// [refreshToken] is the initial refresh token + /// [refreshUrl] is the URL to use for refreshing the token + /// [refreshHeaders] are headers to use for refresh requests + /// [refreshQueryParams] are query parameters to use for refresh requests + /// [onAuth] is a callback to be called when the access token expires + Auth({ + this.accessToken, + this.accessHeaders = const {}, + this.accessQueryParams = const {}, + this.accessTokenExpiryTime, + this.refreshToken, + this.refreshUrl, + this.refreshHeaders = const {}, + this.refreshQueryParams = const {}, + OnAuthCallback? onAuth, + }) : _onAuthRawHandle = onAuth != null + ? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle() + : null; + + /// Convert the Auth instance to JSON + Map toJson() => { + 'accessToken': accessToken, + 'accessHeaders': accessHeaders, + 'accessQueryParams': accessQueryParams, + 'accessTokenExpiryTime': accessTokenExpiryTime?.millisecondsSinceEpoch, + 'refreshToken': refreshToken, + 'refreshUrl': refreshUrl, + 'refreshHeaders': refreshHeaders, + 'refreshQueryParams': refreshQueryParams, + 'onAuthRawHandle': _onAuthRawHandle, + }; + + /// Create an Auth instance from JSON + Auth.fromJson(Map json) + : accessToken = json['accessToken'], + accessHeaders = json['accessHeaders'] != null + ? Map.from(json['accessHeaders']) + : const {}, + accessQueryParams = json['accessQueryParams'] != null + ? Map.from(json['accessQueryParams']) + : const {}, + accessTokenExpiryTime = json['accessTokenExpiryTime'] != null + ? DateTime.fromMillisecondsSinceEpoch( + json['accessTokenExpiryTime'] as int, + ) + : null, + refreshToken = json['refreshToken'], + refreshUrl = json['refreshUrl'], + refreshHeaders = json['refreshHeaders'] != null + ? Map.from(json['refreshHeaders']) + : const {}, + refreshQueryParams = json['refreshQueryParams'] != null + ? Map.from(json['refreshQueryParams']) + : const {}, + _onAuthRawHandle = json['onAuthRawHandle'] as int?; + + /// Returns the [OnAuthCallback] registered with this [Auth], or null + OnAuthCallback? get onAuthCallback => _onAuthRawHandle != null + ? PluginUtilities.getCallbackFromHandle( + CallbackHandle.fromRawHandle(_onAuthRawHandle!), + ) as OnAuthCallback + : null; + + /// Set the [OnAuthCallback] + set onAuthCallback(OnAuthCallback? onAuth) => + _onAuthRawHandle = onAuth != null + ? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle() + : null; + + /// Returns the Uri for accessing the resource + /// + /// Refreshes the [accessToken] if required, and returns the original + /// [url] or [uri] with access query parameters. If headers are used to + /// authenticate, then call [getAccessHeaders] after this call, to make sure + /// they contain updated access tokens. + Future getAccessUri({ + String? url, + Uri? uri, + http.Client? httpClient, + String? refreshUrl, + Uri? refreshUri, + }) async { + if (uri == null && url == null) { + throw ArgumentError('Either uri or url must be provided'); + } + final accessUri = uri ?? Uri.parse(url!); + if (isTokenExpired()) { + // make the refresh request + final (updatedAccesstoken, _) = await refreshAccessToken( + httpClient: httpClient, + refreshUrl: refreshUrl, + refreshUri: refreshUri, + ); + if (!updatedAccesstoken) { + throw const HttpException('Could not refresh access token'); + } + } + return addOrUpdateQueryParams( + uri: accessUri, + queryParams: getAccessQueryParams(), + ); + } + + /// Returns the URL string for accessing the resource + /// + /// Refreshes the [accessToken] if required, and returns the original + /// [url] or [uri] with access query parameters. If headers are used to + /// authenticate, then call [getAccessHeaders] after this call, to make sure + /// they contain updated access tokens. + Future getAccessUrl({ + String? url, + Uri? uri, + http.Client? httpClient, + String? refreshUrl, + Uri? refreshUri, + }) async { + final accessUri = await getAccessUri( + url: url, + uri: uri, + httpClient: httpClient, + refreshUrl: refreshUrl, + refreshUri: refreshUri, + ); + return accessUri.toString(); + } + + /// Refresh the [accessToken] by calling [refreshUrl] or [refreshUri] using + /// [httpClient], adding [refreshHeaders] and [refreshQueryParams] + /// to the request and obtaining the token from the json response + /// 'access_token' and potential 'expires_in'. + /// If the response contains 'refresh_token' that will be updated as well. + /// + /// Returns (bool updatedAccessToken, bool updatedRefreshToken) + /// + /// If neither [refreshUrl] nor [refreshUri] are given, the Auth object's + /// [Auth.refreshUrl] is used + Future<(bool, bool)> refreshAccessToken({ + http.Client? httpClient, + String? refreshUrl, + Uri? refreshUri, + }) async { + if (refreshUrl == null && refreshUri == null && this.refreshUrl == null) { + throw ArgumentError( + 'refreshUrl, refreshUri or Auth object refreshUrl required', + ); + } + var updatedAccessToken = false; + var updatedRefreshToken = false; + final client = httpClient ?? http.Client(); + try { + final selectedRefreshUri = + refreshUri ?? Uri.parse(refreshUrl ?? this.refreshUrl!); + final uri = addOrUpdateQueryParams( + uri: selectedRefreshUri, + queryParams: expandMap(refreshQueryParams), + ); + final headers = expandMap(refreshHeaders); + headers['Content-type'] = 'application/json'; + final response = await client.post( + uri, + headers: headers, + body: jsonEncode({ + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + }), + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final newAuthToken = data['access_token']; + if (newAuthToken != null) { + accessToken = newAuthToken; + updatedAccessToken = true; + final newExpiresIn = data['expires_in']; + if (newExpiresIn != null) { + accessTokenExpiryTime = DateTime.now().add( + Duration(seconds: newExpiresIn), + ); + } + } else { + log.fine( + 'Failed to refresh access token: no access_token in response body.\n' + 'Body is ${response.body}', + ); + } + final newRefreshToken = data['refresh_token']; + if (newRefreshToken != null) { + refreshToken = newRefreshToken; + updatedRefreshToken = true; + } + } else { + log.fine('Failed to refresh token: ${response.statusCode}'); + } + } catch (e) { + log.warning('Error refreshing token: $e'); + } + return (updatedAccessToken, updatedRefreshToken); + } + + /// Returns the headers specified for the access request, with the + /// template {accessToken} and {refreshToken} replaced + Map getAccessHeaders() => expandMap(accessHeaders); + + /// Returns the query params specified for the access request, with the + /// template {accessToken} and {refreshToken} replaced + Map getAccessQueryParams() => expandMap(accessQueryParams); + + /// Add/update the query parameters in this [url] or [uri] with [queryParams] + /// and return the new uri + Uri addOrUpdateQueryParams({ + String? url, + Uri? uri, + Map queryParams = const {}, + }) { + if (uri == null && url == null) { + throw ArgumentError('Either uri or url must be provided'); + } + final startUri = uri ?? Uri.parse(url!); + if (queryParams.isEmpty) { + return startUri; + } + final updatedQueryParams = Map.from( + startUri.queryParameters, + ); + queryParams.forEach((key, value) { + updatedQueryParams[key] = value; + }); + return startUri.replace(queryParameters: updatedQueryParams); + } + + /// Returns true if the [accessTokenExpiryTime is after now plus + /// the [bufferTime], otherwise returns false + bool isTokenExpired({Duration bufferTime = const Duration(seconds: 10)}) { + if (accessTokenExpiryTime == null) return false; + return DateTime.now().add(bufferTime).isAfter(accessTokenExpiryTime!); + } + + /// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken} + /// + /// Returns the expanded map, without changing the original + Map expandMap(Map mapToExpand) { + final (access, refresh) = (accessToken, refreshToken); + final newMap = {}; + mapToExpand.forEach((key, value) { + var newValue = value; + if (access != null) { + newValue = newValue.replaceAll('{accessToken}', access); + } + if (refresh != null) { + newValue = newValue.replaceAll('{refreshToken}', refresh); + } + newMap[key] = newValue; + }); + return newMap; + } +} diff --git a/third_party/background_downloader/lib/src/options/auth_callback.dart b/third_party/background_downloader/lib/src/options/auth_callback.dart new file mode 100644 index 0000000..da16bd9 --- /dev/null +++ b/third_party/background_downloader/lib/src/options/auth_callback.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import '../task.dart'; + +typedef OnAuthCallback = Future Function(Task original); + +/// Default onAuth callback +/// +/// Called when a task starts and the task has an auth object and the +/// object indicated that the access token has expired. +/// The callback attempts to refresh the access token using the information +/// contained in the [Auth] object, and returns the task, which now contains an +/// updated [Auth] object +@pragma("vm:entry-point") +Future defaultOnAuth(Task task) async { + final auth = task.options?.auth; + if (auth == null) { + throw ArgumentError('Task has no auth object'); + } + final (updatedAccessToken, updatedRefreshToken) = await auth + .refreshAccessToken(); + if (!updatedAccessToken) { + throw const HttpException('Could not refresh access token'); + } + return task; +} diff --git a/third_party/background_downloader/lib/src/options/mtls.dart b/third_party/background_downloader/lib/src/options/mtls.dart new file mode 100644 index 0000000..9da45a5 --- /dev/null +++ b/third_party/background_downloader/lib/src/options/mtls.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +/// Holds configuration options for mutual TLS (mTLS) client authentication. +/// +/// Use with [Config.mTLS] to configure client certificate authentication for +/// HTTPS connections on desktop platforms. +/// +/// Provide either file paths ([certificatePath], [privateKeyPath]) or raw bytes +/// ([certificateBytes], [privateKeyBytes]) for the client certificate and private key. +/// An optional private key [password] can be supplied if required. +/// +/// An optional [host] can be specified to restrict this configuration to a specific host +/// (e.g. `'api.example.com'`). If [host] is `null`, the mTLS configuration applies globally +/// (or as the default for all hosts). +/// +/// To reset/clear mTLS for a host or globally, pass an [MTLSConfig] instance with no credentials +/// (or `null`/`false` in configuration). +final class MTLSConfig { + /// Optional host (domain or IP) to which this mTLS configuration applies. + /// If null, applies to all hosts / default. + final String? host; + + /// Path to client certificate / chain file (PEM or DER format). + final String? certificatePath; + + /// Bytes of client certificate / chain file (PEM or DER format). + final List? certificateBytes; + + /// Path to private key file (PEM format). + final String? privateKeyPath; + + /// Bytes of private key file (PEM format). + final List? privateKeyBytes; + + /// Optional password for decrypting the private key or certificate. + final String? password; + + /// Optional path to trusted server / CA certificate file. + final String? serverCertificatePath; + + /// Optional bytes of trusted server / CA certificate file. + final List? serverCertificateBytes; + + /// Creates an [MTLSConfig] instance. + /// + /// Asserts that parameters are legally combined (e.g. not providing both file path + /// and raw bytes for the same asset, and ensuring both certificate and key are provided + /// when configuring credentials). + const MTLSConfig({ + this.host, + this.certificatePath, + this.certificateBytes, + this.privateKeyPath, + this.privateKeyBytes, + this.password, + this.serverCertificatePath, + this.serverCertificateBytes, + }) : assert( + !(certificatePath != null && certificateBytes != null), + 'Cannot provide both certificatePath and certificateBytes', + ), + assert( + !(privateKeyPath != null && privateKeyBytes != null), + 'Cannot provide both privateKeyPath and privateKeyBytes', + ), + assert( + !(serverCertificatePath != null && serverCertificateBytes != null), + 'Cannot provide both serverCertificatePath and serverCertificateBytes', + ), + assert( + ((certificatePath != null || certificateBytes != null) && + (privateKeyPath != null || privateKeyBytes != null)) || + ((certificatePath == null && certificateBytes == null) && + (privateKeyPath == null && privateKeyBytes == null)), + 'Both certificate and privateKey must be provided together, or both omitted for reset', + ); + + /// Returns true if client certificate and key credentials are specified. + bool get hasCredentials => + (certificatePath != null || certificateBytes != null) && + (privateKeyPath != null || privateKeyBytes != null); + + /// Returns true if this configuration is a reset configuration (no credentials). + bool get isReset => !hasCredentials; + + /// Applies this mTLS configuration to the given [context]. + void applyToSecurityContext(SecurityContext context) { + if (certificatePath != null) { + context.useCertificateChain(certificatePath!, password: password); + } else if (certificateBytes != null) { + context.useCertificateChainBytes(certificateBytes!, password: password); + } + + if (privateKeyPath != null) { + context.usePrivateKey(privateKeyPath!, password: password); + } else if (privateKeyBytes != null) { + context.usePrivateKeyBytes(privateKeyBytes!, password: password); + } + + if (serverCertificatePath != null) { + context.setTrustedCertificates( + serverCertificatePath!, + password: password, + ); + } else if (serverCertificateBytes != null) { + context.setTrustedCertificatesBytes( + serverCertificateBytes!, + password: password, + ); + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MTLSConfig && + runtimeType == other.runtimeType && + host == other.host && + certificatePath == other.certificatePath && + _listEquals(certificateBytes, other.certificateBytes) && + privateKeyPath == other.privateKeyPath && + _listEquals(privateKeyBytes, other.privateKeyBytes) && + password == other.password && + serverCertificatePath == other.serverCertificatePath && + _listEquals(serverCertificateBytes, other.serverCertificateBytes); + + @override + int get hashCode => + host.hashCode ^ + certificatePath.hashCode ^ + Object.hashAll(certificateBytes ?? []) ^ + privateKeyPath.hashCode ^ + Object.hashAll(privateKeyBytes ?? []) ^ + password.hashCode ^ + serverCertificatePath.hashCode ^ + Object.hashAll(serverCertificateBytes ?? []); +} + +bool _listEquals(List? a, List? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/third_party/background_downloader/lib/src/options/task_options.dart b/third_party/background_downloader/lib/src/options/task_options.dart new file mode 100644 index 0000000..5d6b258 --- /dev/null +++ b/third_party/background_downloader/lib/src/options/task_options.dart @@ -0,0 +1,116 @@ +import 'dart:ui'; + +import '../models.dart'; +import '../task.dart'; +import 'auth.dart'; + +typedef BeforeTaskStartCallback = Future Function(Task task); + +typedef OnTaskStartCallback = Future Function(Task original); + +typedef OnTaskFinishedCallback = Future Function( + TaskStatusUpdate taskStatusUpdate, +); + +/// Holds various options related to the task that are not included in the +/// task's properties, as they are rare +class TaskOptions { + final int? _beforeTaskStartRawHandle; + final int? _onTaskStartRawHandle; + final int? _onTaskFinishedRawHandle; + + /// The [Auth] object associated with this task + final Auth? auth; + + /// Constructor for [TaskOptions], containing "native" callbacks: + /// + /// * `beforeTaskStart`: a callback called before a task starts executing. + /// The callback receives the `Task` and returns `null` if the task should + /// continue, or a `TaskStatusUpdate` if it should not start - in which + /// case the `TaskStatusUpdate` is posted as the last state update for the task + /// * `onTaskStart`: a callback called before a task starts executing. + /// The callback receives the `Task` and returns `null` if it did not + /// change anything, or a modified `Task` if it needs to use a different + /// url or header. It is called after `onAuth` for token refresh, if that is set + /// * `onTaskFinished`: a callback called when the task has finished. + /// The callback receives the final `TaskStatusUpdate`. + /// * `auth`: an [Auth] object that facilitates management of authorization + /// tokens and refresh tokens, and includes an `onAuth` callback similar to + /// `onTaskStart` + TaskOptions({ + BeforeTaskStartCallback? beforeTaskStart, + OnTaskStartCallback? onTaskStart, + OnTaskFinishedCallback? onTaskFinished, + this.auth, + }) : _beforeTaskStartRawHandle = beforeTaskStart != null + ? PluginUtilities.getCallbackHandle(beforeTaskStart)?.toRawHandle() + : null, + _onTaskStartRawHandle = onTaskStart != null + ? PluginUtilities.getCallbackHandle(onTaskStart)?.toRawHandle() + : null, + _onTaskFinishedRawHandle = onTaskFinished != null + ? PluginUtilities.getCallbackHandle(onTaskFinished)?.toRawHandle() + : null { + assert( + beforeTaskStart == null || _beforeTaskStartRawHandle != null, + _notTopLevel('beforeTaskStart'), + ); + assert( + onTaskStart == null || _onTaskStartRawHandle != null, + _notTopLevel('onTaskStart'), + ); + assert( + onTaskFinished == null || _onTaskFinishedRawHandle != null, + _notTopLevel('onTaskFinished'), + ); + } + + static String _notTopLevel(String callbackName) => + '$callbackName callback must be a top level or static function, ' + 'and marked with @pragma("vm:entry-point")'; + + /// Create the object from JSON + TaskOptions.fromJson(Map json) + : _beforeTaskStartRawHandle = json['beforeTaskStartRawHandle'] as int?, + _onTaskStartRawHandle = json['onTaskStartRawHandle'] as int?, + _onTaskFinishedRawHandle = json['onTaskFinishedRawHandle'] as int?, + auth = json['auth'] != null ? Auth.fromJson(json['auth']) : null; + + /// Returns the [BeforeTaskStartCallback] registered with this [TaskOptions], or null + BeforeTaskStartCallback? get beforeTaskStartCallBack => + _beforeTaskStartRawHandle != null + ? PluginUtilities.getCallbackFromHandle( + CallbackHandle.fromRawHandle(_beforeTaskStartRawHandle), + ) as BeforeTaskStartCallback + : null; + + /// Returns the [OnTaskStartCallback] registered with this [TaskOptions], or null + OnTaskStartCallback? get onTaskStartCallBack => _onTaskStartRawHandle != null + ? PluginUtilities.getCallbackFromHandle( + CallbackHandle.fromRawHandle(_onTaskStartRawHandle), + ) as OnTaskStartCallback + : null; + + /// Returns the [OnTaskFinishedCallback] registered with this [TaskOptions], or null + OnTaskFinishedCallback? get onTaskFinishedCallBack => + _onTaskFinishedRawHandle != null + ? PluginUtilities.getCallbackFromHandle( + CallbackHandle.fromRawHandle(_onTaskFinishedRawHandle), + ) as OnTaskFinishedCallback + : null; + + /// True if [TaskOptions] contains any callback + bool get hasCallback => + beforeTaskStartCallBack != null || + onTaskStartCallBack != null || + onTaskFinishedCallBack != null || + auth?.onAuthCallback != null; + + /// Creates JSON map of this object + Map toJson() => { + 'beforeTaskStartRawHandle': _beforeTaskStartRawHandle, + 'onTaskStartRawHandle': _onTaskStartRawHandle, + 'onTaskFinishedRawHandle': _onTaskFinishedRawHandle, + 'auth': auth?.toJson(), + }; +} diff --git a/third_party/background_downloader/lib/src/permissions.dart b/third_party/background_downloader/lib/src/permissions.dart new file mode 100644 index 0000000..a9e2237 --- /dev/null +++ b/third_party/background_downloader/lib/src/permissions.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import 'native_downloader.dart'; + +enum PermissionType { + notifications, + androidSharedStorage, + iosAddToPhotoLibrary, + iosChangePhotoLibrary, +} + +enum PermissionStatus { undetermined, denied, granted, partial, requestError } + +/// Developer visible interface to the PermissionService +abstract interface class Permissions { + /// Request a permission; returns the [PermissionStatus] + /// + /// Ensure that only one request is being handled at the same time, + /// i.e. wait for the result before requesting another permission + Future request(PermissionType permissionType); + + /// Get the auth status of a permission; returns the [PermissionStatus] + Future status(PermissionType permissionType); + + /// Returns true if the developer should show a rationale for + /// requesting this permission + Future shouldShowRationale(PermissionType permissionType); +} + +/// Basic implementation of the PermissionsService +base class PermissionsService implements Permissions { + final log = Logger("PermissionService"); + + PermissionsService(); + + /// Creates a [PermissionsService] appropriate for this platform + factory PermissionsService.instance() => switch (defaultTargetPlatform) { + .android => AndroidPermissionsService(), + .iOS => IOSPermissionsService(), + .linux || .macOS || .windows => PermissionsService(), + _ => throw ArgumentError( + 'Platform $defaultTargetPlatform is not supported', + ), + }; + + @override + Future request(PermissionType permissionType) => + Future.value(PermissionStatus.granted); + + @override + Future shouldShowRationale(PermissionType permissionType) => + Future.value(false); + + @override + Future status(PermissionType permissionType) => + Future.value(PermissionStatus.granted); + + /// Process the permissionRequest result sent from the native side + /// (Android only) + void onPermissionRequestResult(PermissionStatus permissionStatus) {} +} + +final class IOSPermissionsService extends PermissionsService { + IOSPermissionsService(); + + @override + Future request(PermissionType permissionType) async { + final result = await NativeDownloader.methodChannel.invokeMethod( + 'requestPermission', + permissionType.index, + ); + return result != null + ? PermissionStatus.values[result] + : PermissionStatus.requestError; + } + + @override + Future status(PermissionType permissionType) async { + final result = await NativeDownloader.methodChannel.invokeMethod( + 'permissionStatus', + permissionType.index, + ); + return result != null + ? PermissionStatus.values[result] + : PermissionStatus.requestError; + } +} + +final class AndroidPermissionsService extends IOSPermissionsService { + Completer? permissionStatusCompleter; + + @override + Future request(PermissionType permissionType) async { + if (![ + PermissionType.notifications, + PermissionType.androidSharedStorage, + ].contains(permissionType)) { + return PermissionStatus.granted; + } + if (permissionStatusCompleter != null) { + log.warning( + 'Permission request already in progress - failing this one immediately', + ); + return PermissionStatus.requestError; + } + final request = Completer(); + permissionStatusCompleter = request; + try { + final waitForCompletion = await NativeDownloader.methodChannel + .invokeMethod('requestPermission', permissionType.index); + if (waitForCompletion == true) { + // The callback can clear the shared slot before invokeMethod replies. + return await request.future; + } + log.warning( + 'Failure to request for permission - was it already granted?', + ); + return PermissionStatus.requestError; + } finally { + // Release only this request's slot, including platform dispatch errors. + if (identical(permissionStatusCompleter, request)) { + permissionStatusCompleter = null; + } + } + } + + @override + Future shouldShowRationale(PermissionType permissionType) async { + final result = await NativeDownloader.methodChannel.invokeMethod( + 'shouldShowPermissionRationale', + permissionType.index, + ); + return result ?? false; + } + + @override + void onPermissionRequestResult(PermissionStatus permissionStatus) { + permissionStatusCompleter?.complete(permissionStatus); + permissionStatusCompleter = null; + } +} diff --git a/third_party/background_downloader/lib/src/persistent_storage.dart b/third_party/background_downloader/lib/src/persistent_storage.dart new file mode 100644 index 0000000..14d522f --- /dev/null +++ b/third_party/background_downloader/lib/src/persistent_storage.dart @@ -0,0 +1,690 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'base_downloader.dart'; +import 'database.dart'; +import 'localstore/localstore.dart'; +import 'models.dart'; +import 'task.dart'; + +/// Interface for the persistent storage used to back the downloader +/// +/// Defines 'store', 'retrieve', 'retrieveAll' and 'remove' methods for: +/// - [TaskRecord]s, keyed by taskId +/// - paused [Task]s, keyed by taskId +/// - [ResumeData], keyed by taskId +/// +/// Each of the objects has a toJson method and can be created using +/// fromJson (use .createFromJson for [Task] objects) +/// +/// Also defined methods to allow migration from one database version to another +abstract interface class PersistentStorage { + /// Store a [TaskRecord], keyed by taskId + Future storeTaskRecord(TaskRecord record); + + /// Retrieve [TaskRecord] with [taskId], or null if not found + Future retrieveTaskRecord(String taskId); + + /// Retrieve all [TaskRecord] + Future> retrieveAllTaskRecords(); + + /// Remove [TaskRecord] with [taskId] from storage. If null, remove all + Future removeTaskRecord(String? taskId); + + /// Store a paused [task], keyed by taskId + Future storePausedTask(Task task); + + /// Retrieve paused [Task] with [taskId], or null if not found + Future retrievePausedTask(String taskId); + + /// Retrieve all paused [Task] + Future> retrieveAllPausedTasks(); + + /// Remove paused [Task] with [taskId] from storage. If null, remove all + Future removePausedTask(String? taskId); + + /// Store [ResumeData], keyed by its taskId + Future storeResumeData(ResumeData resumeData); + + /// Retrieve [ResumeData] with [taskId], or null if not found + Future retrieveResumeData(String taskId); + + /// Retrieve all [ResumeData] + Future> retrieveAllResumeData(); + + /// Remove [ResumeData] with [taskId] from storage. If null, remove all + Future removeResumeData(String? taskId); + + /// Name and version number for this type of persistent storage + /// + /// Used for database migration: this is the version represented by the code + (String, int) get currentDatabaseVersion; + + /// Name and version number for database as stored + /// + /// Used for database migration, may be 'older' than the code version + Future<(String, int)> get storedDatabaseVersion; + + /// Initialize the database - only called when the [BaseDownloader] + /// is created with this object, which happens when the [FileDownloader] + /// singleton is instantiated, OR as part of a migration away from this + /// database type. + /// + /// Migrates the data from stored name and version to the current + /// name and version, if needed + /// This call runs async with the rest of the initialization + Future initialize(); +} + +enum _StorageCommand { + getStoredDatabaseVersion, + initialize, + storeTaskRecord, + retrieveTaskRecord, + retrieveAllTaskRecords, + removeTaskRecord, + storePausedTask, + retrievePausedTask, + retrieveAllPausedTasks, + removePausedTask, + storeResumeData, + retrieveResumeData, + retrieveAllResumeData, + removeResumeData, + retrieveAll, + clearCache, +} + +/// Default implementation of [PersistentStorage] using Localstore package +/// +/// Runs the actual [Localstore] based storage on a background isolate to +/// prevent jank on the main thread +class LocalStorePersistentStorage implements PersistentStorage { + static const taskRecordsPath = + _LocalStorePersistentStorageExecutor.taskRecordsPath; + static const resumeDataPath = + _LocalStorePersistentStorageExecutor.resumeDataPath; + static const pausedTasksPath = + _LocalStorePersistentStorageExecutor.pausedTasksPath; + + final log = Logger('LocalStorePersistentStorage'); + SendPort? _sendPort; + final _responseCompleters = >{}; + int _nextRequestId = 0; + + @override + (String, int) get currentDatabaseVersion => ('Localstore', 1); + + @override + Future<(String, int)> get storedDatabaseVersion async { + final result = await _sendRequest>( + _StorageCommand.getStoredDatabaseVersion, + [], + ); + return (result[0] as String, result[1] as int); + } + + Future? _initializationFuture; + + @override + Future initialize() { + if (_sendPort != null) { + return Future.value(); + } + _initializationFuture ??= _doInitialize(); + return _initializationFuture!; + } + + Future _doInitialize() async { + try { + final receivePort = ReceivePort(); + await Isolate.spawn(_isolateEntry, receivePort.sendPort); + _sendPort = await receivePort.first as SendPort; + + // Listen for responses + final responsePort = ReceivePort(); + _sendPort!.send(responsePort.sendPort); // Send response port to isolate + responsePort.listen(_handleResponse); + + // Initialize the executor in the isolate + // pass the RootIsolateToken to allow background isolate to use platform channels + // for path_provider + final rootIsolateToken = RootIsolateToken.instance; + await _sendRequest(_StorageCommand.initialize, [rootIsolateToken]); + } catch (e) { + _initializationFuture = null; // allow retry + rethrow; + } + } + + void _handleResponse(dynamic message) { + if (message is List && message.length >= 2) { + final requestId = message[0] as int; + final response = message[1]; + final error = message.length > 2 ? message[2] : null; + + final completer = _responseCompleters.remove(requestId); + if (completer != null) { + if (error != null) { + completer.completeError(error); + } else { + completer.complete(response); + } + } + } + } + + Future _sendRequest(_StorageCommand method, List args) async { + if (_sendPort == null) { + await initialize(); + } + final completer = Completer(); + final requestId = _nextRequestId++; + _responseCompleters[requestId] = completer; + _sendPort!.send([requestId, method, args]); + return completer.future; + } + + @override + Future storeTaskRecord(TaskRecord record) => + _sendRequest(_StorageCommand.storeTaskRecord, [record.toJson()]); + + @override + Future retrieveTaskRecord(String taskId) async { + final result = await _sendRequest( + _StorageCommand.retrieveTaskRecord, + [taskId], + ); + return result; + } + + @override + Future> retrieveAllTaskRecords() async { + final result = await _sendRequest>( + _StorageCommand.retrieveAllTaskRecords, + [], + ); + return result.cast(); + } + + @override + Future removeTaskRecord(String? taskId) => + _sendRequest(_StorageCommand.removeTaskRecord, [taskId]); + + @override + Future storePausedTask(Task task) => + _sendRequest(_StorageCommand.storePausedTask, [task.toJson()]); + + @override + Future retrievePausedTask(String taskId) async { + final result = await _sendRequest( + _StorageCommand.retrievePausedTask, + [taskId], + ); + return result; + } + + @override + Future> retrieveAllPausedTasks() async { + final result = await _sendRequest>( + _StorageCommand.retrieveAllPausedTasks, + [], + ); + return result.cast(); + } + + @override + Future removePausedTask(String? taskId) => + _sendRequest(_StorageCommand.removePausedTask, [taskId]); + + @override + Future storeResumeData(ResumeData resumeData) => + _sendRequest(_StorageCommand.storeResumeData, [resumeData.toJson()]); + + @override + Future retrieveResumeData(String taskId) async { + final result = await _sendRequest( + _StorageCommand.retrieveResumeData, + [taskId], + ); + return result; + } + + @override + Future> retrieveAllResumeData() async { + final result = await _sendRequest>( + _StorageCommand.retrieveAllResumeData, + [], + ); + return result.cast(); + } + + @override + Future removeResumeData(String? taskId) => + _sendRequest(_StorageCommand.removeResumeData, [taskId]); + + Future> retrieveAll(String collection) async { + final result = await _sendRequest>( + _StorageCommand.retrieveAll, + [collection], + ); + return result; + } + + Future clearCache() => _sendRequest(_StorageCommand.clearCache, []); +} + +// Entry point for the isolate +void _isolateEntry(SendPort mainSendPort) { + final receivePort = ReceivePort(); + mainSendPort.send(receivePort.sendPort); + + SendPort? responseSendPort; + _LocalStorePersistentStorageExecutor? executor; + + receivePort.listen((message) async { + if (message is SendPort) { + responseSendPort = message; + } else if (message is List) { + final requestId = message[0] as int; + final method = message[1] as _StorageCommand; + final args = message[2] as List; + + try { + dynamic result; + if (method == _StorageCommand.initialize) { + final token = args[0] as RootIsolateToken?; + if (token != null) { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + } + if (token != null) { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + } + executor = _LocalStorePersistentStorageExecutor(); + await executor!.initialize(); + executor!.log.fine( + 'Initialized isolate with dbDir ${await Localstore.instance.databaseDirectory}', + ); + result = null; + } else { + // All other methods require executor to be initialized + if (executor == null) { + throw StateError('Executor not initialized'); + } + result = await _dispatch(executor!, method, args); + } + responseSendPort?.send([requestId, result]); + } catch (e) { + responseSendPort?.send([requestId, null, e.toString()]); + } + } + }); +} + +Future _dispatch( + _LocalStorePersistentStorageExecutor executor, + _StorageCommand method, + List args, +) => switch (method) { + _StorageCommand.getStoredDatabaseVersion => + executor.storedDatabaseVersion.then((r) => [r.$1, r.$2]), // tuple to list + _StorageCommand.storeTaskRecord => executor.storeTaskRecord( + args[0] as Map, + ), + _StorageCommand.retrieveTaskRecord => executor.retrieveTaskRecord( + args[0] as String, + ), + _StorageCommand.retrieveAllTaskRecords => executor.retrieveAllTaskRecords(), + _StorageCommand.removeTaskRecord => executor.removeTaskRecord( + args[0] as String?, + ), + _StorageCommand.storePausedTask => executor.storePausedTask( + args[0] as Map, + ), + _StorageCommand.retrievePausedTask => executor.retrievePausedTask( + args[0] as String, + ), + _StorageCommand.retrieveAllPausedTasks => executor.retrieveAllPausedTasks(), + _StorageCommand.removePausedTask => executor.removePausedTask( + args[0] as String?, + ), + _StorageCommand.storeResumeData => executor.storeResumeData( + args[0] as Map, + ), + _StorageCommand.retrieveResumeData => executor.retrieveResumeData( + args[0] as String, + ), + _StorageCommand.retrieveAllResumeData => executor.retrieveAllResumeData(), + _StorageCommand.removeResumeData => executor.removeResumeData( + args[0] as String?, + ), + _StorageCommand.retrieveAll => executor.retrieveAll(args[0] as String), + _StorageCommand.clearCache => executor.clearCache(), + _StorageCommand.initialize => throw StateError( + 'Initialize should be handled in isolate entry', + ), +}; + +/// The executor that runs in the isolate and does the actual work +class _LocalStorePersistentStorageExecutor { + final log = Logger('LocalStorePersistentStorageExecutor'); + final _db = Localstore.instance; + final _illegalPathCharacters = RegExp(r'[\\/:*?"<>|]'); + + static const taskRecordsPath = 'backgroundDownloaderTaskRecords'; + static const resumeDataPath = 'backgroundDownloaderResumeData'; + static const pausedTasksPath = 'backgroundDownloaderPausedTasks'; + static const metaDataCollection = 'backgroundDownloaderDatabase'; + + Future storeTaskRecord(Map recordJson) async { + final taskId = recordJson['taskId'] as String; + await store(recordJson, taskRecordsPath, _safeId(taskId)); + } + + Future retrieveTaskRecord(String taskId) async { + final json = await retrieve(taskRecordsPath, _safeId(taskId)); + return json != null ? TaskRecord.fromJson(json) : null; + } + + Future> retrieveAllTaskRecords() async { + final jsonMaps = await retrieveAll(taskRecordsPath); + return jsonMaps.values + .map((e) => TaskRecord.fromJson(e as Map)) + .toList(); + } + + Future removeTaskRecord(String? taskId) => + remove(taskRecordsPath, _safeIdOrNull(taskId)); + + Future storePausedTask(Map taskJson) async { + final taskId = taskJson['taskId'] as String; + await store(taskJson, pausedTasksPath, _safeId(taskId)); + } + + Future retrievePausedTask(String taskId) async { + final json = await retrieve(pausedTasksPath, _safeId(taskId)); + return json != null ? Task.createFromJson(json) : null; + } + + Future> retrieveAllPausedTasks() async { + final jsonMaps = await retrieveAll(pausedTasksPath); + return jsonMaps.values + .map((e) => Task.createFromJson(e as Map)) + .toList(); + } + + Future removePausedTask(String? taskId) => + remove(pausedTasksPath, _safeIdOrNull(taskId)); + + Future storeResumeData(Map dataJson) async { + final taskMap = dataJson['task'] as Map; + final taskId = taskMap['taskId'] as String; + await store(dataJson, resumeDataPath, _safeId(taskId)); + } + + Future retrieveResumeData(String taskId) async { + final json = await retrieve(resumeDataPath, _safeId(taskId)); + return json != null ? ResumeData.fromJson(json) : null; + } + + Future> retrieveAllResumeData() async { + final jsonMaps = await retrieveAll(resumeDataPath); + return jsonMaps.values + .map((e) => ResumeData.fromJson(e as Map)) + .toList(); + } + + Future removeResumeData(String? taskId) => + remove(resumeDataPath, _safeIdOrNull(taskId)); + + /// Stores [Map] formatted [document] in [collection] keyed under [identifier] + Future store( + Map document, + String collection, + String identifier, + ) async { + await _db.collection(collection).doc(identifier).set(document); + } + + /// Returns [document] stored in [collection] under key [identifier] + /// as a [Map], or null if not found + Future?> retrieve( + String collection, + String identifier, + ) => _db.collection(collection).doc(identifier).get(); + + /// Returns all documents in collection as a [Map] keyed by the + /// document identifier, with the value a [Map] representing the document + Future> retrieveAll(String collection) async => + await _db.collection(collection).get() ?? {}; + + /// Removes document with [identifier] from [collection] + /// + /// If [identifier] is null, removes all documents in the [collection] + Future remove(String collection, [String? identifier]) async { + if (identifier == null) { + await _db.collection(collection).delete(); + } else { + await _db.collection(collection).doc(identifier).delete(); + } + } + + /// Returns possibly modified id, safe for storing in the localStore + String _safeId(String id) => id.replaceAll(_illegalPathCharacters, '_'); + + /// Returns possibly modified id, safe for storing in the localStore, or null + /// if [id] is null + String? _safeIdOrNull(String? id) => + id?.replaceAll(_illegalPathCharacters, '_'); + + Future<(String, int)> get storedDatabaseVersion async { + final metaData = await _db + .collection(metaDataCollection) + .doc('metaData') + .get(); + return ('Localstore', (metaData?['version'] as num?)?.toInt() ?? 0); + } + + (String, int) get currentDatabaseVersion => ('Localstore', 1); + + Future initialize() async { + final (currentName, currentVersion) = currentDatabaseVersion; + final (storedName, storedVersion) = await storedDatabaseVersion; + if (storedName != currentName) { + log.warning('Cannot migrate from database name $storedName'); + return; + } + if (storedVersion == currentVersion) { + return; + } + log.fine( + 'Migrating $currentName database from version $storedVersion to $currentVersion', + ); + switch (storedVersion) { + case 0: + // move files from docDir to supportDir + final docDir = await getApplicationDocumentsDirectory(); + final supportDir = await getApplicationSupportDirectory(); + await Future.wait( + [resumeDataPath, pausedTasksPath, taskRecordsPath].map((path) async { + try { + final fromPath = join(docDir.path, path); + if (await Directory(fromPath).exists()) { + log.finest('Moving $path to support directory'); + final toPath = join(supportDir.path, path); + await Directory(toPath).create(recursive: true); + final entities = await Directory(fromPath).list().toList(); + await Future.wait( + entities.whereType().map( + (file) => file.copy(join(toPath, basename(file.path))), + ), + ); + await Directory(fromPath).delete(recursive: true); + } + } catch (e) { + log.fine('Error migrating database for path $path: $e'); + } + }), + ); + + default: + log.warning('Illegal starting version: $storedVersion'); + } + await _db.collection(metaDataCollection).doc('metaData').set({ + 'version': currentVersion, + }); + } + + Future clearCache() async { + Localstore.instance.clearCache(); + } +} + +/// Interface to migrate from one persistent storage to another +abstract interface class PersistentStorageMigrator { + /// Migrate data from one of the [migrationOptions] to the [toStorage] + /// + /// If migration took place, returns the name of the migration option, + /// otherwise returns null + Future migrate( + List migrationOptions, + PersistentStorage toStorage, + ); +} + +/// Migrates from [LocalStorePersistentStorage] to another [PersistentStorage] +class BasePersistentStorageMigrator implements PersistentStorageMigrator { + final log = Logger('PersistentStorageMigrator'); + + /// Create [BasePersistentStorageMigrator] object to migrate between persistent + /// storage solutions + /// + /// [BasePersistentStorageMigrator] only migrates from: + /// * local_store (the default implementation of the database in + /// background_downloader). + /// + /// To add other migrations, extend this class and inject it in the + /// [PersistentStorage] class that you want to migrate to. + /// + /// See package background_downloader_sql for an implementation + /// that migrates to a SQLite based [PersistentStorage], including + /// migration from Flutter Downloader + BasePersistentStorageMigrator(); + + /// Migrate data from one of the [migrationOptions] to the [toStorage] + /// + /// If migration took place, returns the name of the migration option, + /// otherwise returns null + /// + /// This is the public interface to use in other [PersistentStorage] + /// solutions. + @override + Future migrate( + List migrationOptions, + PersistentStorage toStorage, + ) async { + for (final persistentStorageName in migrationOptions) { + try { + if (await migrateFrom(persistentStorageName, toStorage)) { + return persistentStorageName; + } + } on Exception catch (e, stacktrace) { + log.warning( + 'Error attempting to migrate from $persistentStorageName: $e\n$stacktrace', + ); + } + } + return null; // no migration + } + + /// Attempt to migrate data from [persistentStorageName] to [toStorage] + /// + /// Returns true if the migration was successfully executed, false if it + /// was not a viable migration + /// + /// If extending the class, add your mapping from a migration option String + /// to a _migrateFrom... method that does your migration. + Future migrateFrom( + String persistentStorageName, + PersistentStorage toStorage, + ) => switch (persistentStorageName.toLowerCase().replaceAll('_', '')) { + 'localstore' => migrateFromLocalStore(toStorage), + _ => Future.value(false), + }; + + /// Migrate from a persistent storage to our database + /// + /// Returns true if this migration took place + /// + /// This is a generic migrator that copies from one storage to another, and + /// is used by the _migrateFrom... methods + Future migrateFromPersistentStorage( + PersistentStorage fromStorage, + PersistentStorage toStorage, + ) async { + bool migratedSomething = false; + await fromStorage.initialize(); + final pausedTasks = await fromStorage.retrieveAllPausedTasks(); + if (pausedTasks.isNotEmpty) { + await Future.wait(pausedTasks.map((e) => toStorage.storePausedTask(e))); + migratedSomething = true; + } + final resumeData = await fromStorage.retrieveAllResumeData(); + if (resumeData.isNotEmpty) { + await Future.wait(resumeData.map((e) => toStorage.storeResumeData(e))); + migratedSomething = true; + } + final taskRecords = await fromStorage.retrieveAllTaskRecords(); + if (taskRecords.isNotEmpty) { + await Future.wait(taskRecords.map((e) => toStorage.storeTaskRecord(e))); + migratedSomething = true; + } + return migratedSomething; + } + + /// Attempt to migrate from [LocalStorePersistentStorage] + /// + /// Return true if successful. Successful migration removes the original + /// data + /// + /// If extending this class, add a method like this that does the + /// migration by: + /// 1. Setting up the [PersistentStorage] object you want to migrate from + /// 2. Call [migrateFromPersistentStorage] to do the transfer from that + /// object to the new object, passed as [toStorage] + /// 3. Remove all traces of the [PersistentStorage] object you want to migrate + /// from + Future migrateFromLocalStore(PersistentStorage toStorage) async { + final localStore = LocalStorePersistentStorage(); + if (await migrateFromPersistentStorage(localStore, toStorage)) { + // delete all paths related to LocalStore + final supportDir = await getApplicationSupportDirectory(); + await Future.wait( + [ + _LocalStorePersistentStorageExecutor.resumeDataPath, + _LocalStorePersistentStorageExecutor.pausedTasksPath, + _LocalStorePersistentStorageExecutor.taskRecordsPath, + _LocalStorePersistentStorageExecutor.metaDataCollection, + ].map((collectionPath) async { + try { + final path = join(supportDir.path, collectionPath); + if (await Directory(path).exists()) { + log.finest('Removing directory $path for LocalStore'); + await Directory(path).delete(recursive: true); + } + } catch (e) { + log.fine('Error deleting collection path $collectionPath: $e'); + } + }), + ); + return true; // we migrated a database + } + return false; // we did not migrate a database + } +} diff --git a/third_party/background_downloader/lib/src/progress_widget.dart b/third_party/background_downloader/lib/src/progress_widget.dart new file mode 100644 index 0000000..5900859 --- /dev/null +++ b/third_party/background_downloader/lib/src/progress_widget.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import 'file_downloader.dart'; +import 'models.dart'; +import 'task.dart'; +import 'uri/uri_helpers.dart'; + +/// Progress indicator for use with the [FileDownloader] +/// +/// Configuration parameters: +/// [message] message to show for a single download. Templates {filename} and +/// {metadata} are replaced by a task's filename and metadata respectively +/// [collapsedMessage] message to show when multiple file downloads are +/// collapsed into a single row. The template {n} is replaced by the number +/// of tasks finished, and {total} is replaced by the total number of tasks +/// started in this batch. Both reset to 0 when all downloads finish. +/// In collapsed mode, progress is indicated as {n}/{total} +/// [showPauseButton] if true, shows a pause button if the task allows it +/// [showCancelButton] if true, shows a cancel button +/// [height] height of the [DownloadProgressIndicator], and of each row when +/// in expanded mode +/// [maxExpandable] maximum number of rows the indicator can expand to, with +/// each row showing one download in progress. If set to 1 (the default) the +/// indicator will not expand: if the number of downloads in progress exceeds +/// 1, the indicator will switch to a 'collapsed' state showing the +/// number of files finished out of the total started. It will stay in +/// 'collapsed' state until all downloads have finished. To start the +/// indicator in collapsed state (i.e. never show the download progress of a +/// single file), set [maxExpandable] to 0 +/// [backgroundColor] background color for the widget +class DownloadProgressIndicator extends StatefulWidget { + const DownloadProgressIndicator( + this.updates, { + this.message = '{filename}', + this.collapsedMessage = '{n}/{total}', + this.showPauseButton = false, + this.showCancelButton = false, + this.height = 50, + this.maxExpandable = 1, + this.backgroundColor = Colors.grey, + super.key, + }); + + final Stream updates; + final String message; + final String collapsedMessage; + final bool showPauseButton; + final bool showCancelButton; + final double height; + final int maxExpandable; + final Color backgroundColor; + + @override + State createState() => + _DownloadProgressIndicatorState(); +} + +class _DownloadProgressIndicatorState extends State { + StreamSubscription? downloadUpdates; + final inProgress = {}; + final pausedTasks = {}; + final totalTasks = {}; + final finishedTasks = {}; + bool isExpanded = false; + bool isCollapsed = false; + + @override + void initState() { + super.initState(); + isCollapsed = widget.maxExpandable == 0; + downloadUpdates = widget.updates.listen((update) { + if (update is TaskStatusUpdate) { + switch (update.status) { + case .running: + totalTasks.add(update.task); + pausedTasks.remove(update.task); + inProgress[update.task] = ( + 0, + DateTime.now().millisecondsSinceEpoch, + ); + + case .waitingToRetry: + break; + + case .paused: + pausedTasks.add(update.task); + + default: + // task finished + _finish(update.task); + } + } else if (update is TaskProgressUpdate) { + switch (update.progress) { + case >= 0 && < 1: + // active, so add task to set and update progress + totalTasks.add(update.task); + pausedTasks.remove(update.task); + final previousInProgress = inProgress[update.task]; + inProgress[update.task] = ( + update.progress, + previousInProgress?.$2 ?? DateTime.now().millisecondsSinceEpoch, + ); + + case progressWaitingToRetry: + break; + + case progressPaused: + pausedTasks.add(update.task); + + default: + // task finished + _finish(update.task); + } + if (mounted) { + setState(() {}); + } + } + }); + } + + @override + void dispose() { + super.dispose(); + downloadUpdates?.cancel(); + downloadUpdates = null; + } + + /// Process a finished task + /// + /// Remove the task from [inProgress] and [pausedTasks], add to [finishedTasks] + /// If [inProgress] is empty, also empty the sets and reset + /// the [isExpanded] and [isCollapsed] flags. + void _finish(Task task) { + inProgress.remove(task); + finishedTasks.add(task); + pausedTasks.remove(task); + if (inProgress.isEmpty) { + totalTasks.clear(); + finishedTasks.clear(); + isExpanded = false; + isCollapsed = widget.maxExpandable == 0; + } + } + + @override + Widget build(BuildContext context) { + final activeTasks = inProgress.keys + .where((taskId) => inProgress[taskId]!.$1 >= 0) + .sorted((a, b) => inProgress[a]!.$2.compareTo(inProgress[b]!.$2)); + final numActive = activeTasks.length; + if (numActive > 1) { + if (widget.maxExpandable > 1) { + isExpanded = true; // only resets when all downloads finish + } else { + isCollapsed = true; // only resets when all downloads finish + } + } + final itemsToShow = isExpanded + ? min(numActive, widget.maxExpandable) + : isCollapsed + ? min(1, numActive) + : numActive; + return AnimatedSize( + duration: const Duration(milliseconds: 200), + alignment: Alignment.bottomCenter, + child: switch (itemsToShow) { + 0 => Container(height: 0), + 1 => + isCollapsed + ? _CollapsedDownloadProgress( + finishedTasks.length, + totalTasks.length, + widget.collapsedMessage, + widget.height, + widget.backgroundColor, + ) + : _DownloadProgressItem( + activeTasks.first, + inProgress[activeTasks.first]!.$1, + widget.message, + widget.showPauseButton, + widget.showCancelButton, + widget.height, + widget.backgroundColor, + pausedTasks, + ), + _ => _ExpandedDownloadProgress( + activeTasks.take(widget.maxExpandable).toList(growable: false), + widget.message, + widget.height, + widget.backgroundColor, + inProgress, + ), + }, + ); + } +} + +final _fileNameRegEx = RegExp("""{filename}""", caseSensitive: false); +final _metadataRegEx = RegExp("""{metadata}""", caseSensitive: false); + +/// Single file download progress widget +class _DownloadProgressItem extends StatelessWidget { + const _DownloadProgressItem( + this.task, + this.progress, + this.message, + this.showPauseButton, + this.showCancelButton, + this.height, + this.backgroundColor, + this.pausedTasks, + ); + + final Task task; + final double progress; + final String message; + final bool showPauseButton; + final bool showCancelButton; + final double height; + final Color backgroundColor; + final Set pausedTasks; + + @override + Widget build(BuildContext context) { + final messageText = message + .replaceAll( + _fileNameRegEx, + unpack(task.filename).filename ?? task.filename, + ) + .replaceAll(_metadataRegEx, task.metaData); + return Container( + height: height, + decoration: BoxDecoration(color: backgroundColor), + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + messageText, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Expanded( + child: LinearProgressIndicator( + value: progress == 0 ? null : progress, + ), + ), + if (showPauseButton && + task.allowPause && + !pausedTasks.contains(task)) + IconButton( + onPressed: () => FileDownloader().pause(task as DownloadTask), + icon: const Icon(Icons.pause), + color: Theme.of(context).primaryColor, + ), + if (showPauseButton && + task.allowPause && + pausedTasks.contains(task)) + IconButton( + onPressed: () => FileDownloader().resume(task as DownloadTask), + icon: const Icon(Icons.play_arrow), + color: Theme.of(context).primaryColor, + ), + if (showCancelButton) + IconButton( + onPressed: () => FileDownloader().cancelTaskWithId(task.taskId), + icon: const Icon(Icons.cancel), + color: Theme.of(context).primaryColor, + ), + ], + ), + ), + ); + } +} + +/// Collapsed file download progress widget, showing the number of files +/// downloaded out of the total number of files downloaded as a progress +/// indicator +class _CollapsedDownloadProgress extends StatelessWidget { + const _CollapsedDownloadProgress( + this.numCompleted, + this.numTotal, + this.collapsedMessage, + this.height, + this.backgroundColor, + ); + + final int numCompleted; + final int numTotal; + final String collapsedMessage; + final double height; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + final messageText = collapsedMessage + .replaceAll('{n}', '$numCompleted') + .replaceAll('{total}', '$numTotal'); + final progress = numCompleted / numTotal; + + return Container( + height: height, + decoration: BoxDecoration(color: backgroundColor), + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + messageText, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Expanded(child: LinearProgressIndicator(value: progress)), + ], + ), + ), + ); + } +} + +/// Expanded file download progress widget, showing multiple file download +/// progress indicators (up to expandable as defined in the core widget +class _ExpandedDownloadProgress extends StatelessWidget { + const _ExpandedDownloadProgress( + this.tasks, + this.message, + this.height, + this.backgroundColor, + this.inProgress, + ); + + final List tasks; + final String message; + final double height; + final Color backgroundColor; + final Map inProgress; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + children: tasks + .map( + (task) => TableRow( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + children: [ + SizedBox( + height: height, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: Text( + message + .replaceAll( + _fileNameRegEx, + unpack(task.filename).filename ?? task.filename, + ) + .replaceAll(_metadataRegEx, task.metaData), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: LinearProgressIndicator(value: inProgress[task]!.$1), + ), + ], + ), + ) + .toList(), + ), + ); +} diff --git a/third_party/background_downloader/lib/src/queue/serial_job_queue.dart b/third_party/background_downloader/lib/src/queue/serial_job_queue.dart new file mode 100644 index 0000000..722e41a --- /dev/null +++ b/third_party/background_downloader/lib/src/queue/serial_job_queue.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'dart:collection'; + +/// A queue that processes jobs serially, one after another. +/// +/// [J] is the type of the job data. +/// [R] is the type of the result returned by the processor. +class SerialJobQueue(final Future Function(J) _processor) { + final Queue<_Job> _queue = Queue(); + bool _isProcessing = false; + + /// Adds data to the queue and returns a Future that completes + /// when this specific item has been processed. + Future add(J data) { + final completer = Completer(); + _queue.add(_Job(data, completer)); + + // Trigger the processing loop if it's not already running + _processQueue(); + + return completer.future; + } + + Future _processQueue() async { + // If already running, do nothing. The loop will pick up the new item. + if (_isProcessing) return; + + _isProcessing = true; + + while (_queue.isNotEmpty) { + final job = _queue.removeFirst(); + try { + // Await the processing to ensure order + final result = await _processor(job.data); + job.completer.complete(result); + } catch (e, stack) { + job.completer.completeError(e, stack); + } + } + + _isProcessing = false; + } +} + +// Simple container for the data and the completer +class _Job(final J data, final Completer completer); diff --git a/third_party/background_downloader/lib/src/queue/task_queue.dart b/third_party/background_downloader/lib/src/queue/task_queue.dart new file mode 100644 index 0000000..18eb549 --- /dev/null +++ b/third_party/background_downloader/lib/src/queue/task_queue.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; + +import '../file_downloader.dart'; +import '../task.dart'; + +/// Interface allowing the [FileDownloader] to signal finished tasks to +/// a [TaskQueue] +abstract interface class TaskQueue { + /// Signals that [task] has finished + void taskFinished(Task task); + + /// Pauses task processing in the queue. + /// + /// If [tasks] or [group] are provided, pauses only those tasks. + /// If both are null, pauses all tasks. + /// + /// Default implementation is a no-op to ensure backwards compatibility + /// with subclasses that don't override this method + Future pauseAll({Iterable? tasks, String? group}) async {} + + /// Resumes task processing in the queue + /// + /// If [tasks] or [group] are provided, resumes only those tasks. + /// If both are null, resumes all tasks. + /// + /// Default implementation is a no-op to ensure backwards compatibility + /// with subclasses that don't override this method + Future resumeAll({ + Iterable? tasks, + String? group, + }) async {} +} + +/// TaskQueue that holds all information in memory +class MemoryTaskQueue implements TaskQueue { + static final _log = Logger('MemoryTaskQueue'); + static const unlimited = 1 << 20; + + /// Tasks waiting to be enqueued, in priority order + final waiting = PriorityQueue(); + + /// Max number of tasks running concurrently, default is unlimited + int maxConcurrent = unlimited; + + /// Max number of active tasks connecting to the same host concurrently, + /// default is unlimited + int maxConcurrentByHost = unlimited; + + /// Max number of active tasks with the same group concurrently, + /// default is unlimited + int maxConcurrentByGroup = unlimited; + + /// Minimum interval between successive enqueues, set to avoid choking the + /// message loop when adding many tasks + Duration minInterval = const Duration(milliseconds: 20); + + /// Set of tasks that have been enqueued with the FileDownloader + final enqueued = {}; // by TaskId + + /// Active tasks count by hostname + final _activeByHost = {}; + + /// Active tasks count by group + final _activeByGroup = {}; + + var _readyForEnqueue = Completer(); + + final _enqueueErrorsStreamController = StreamController(); + + var _paused = false; + final _pausedTaskIds = {}; + + MemoryTaskQueue() { + _readyForEnqueue.complete(); + } + + @override + Future pauseAll({Iterable? tasks, String? group}) async { + if (tasks == null && group == null) { + _paused = true; + } else { + // pause specific tasks/groups + if (group != null) { + final tasksToPause = waiting.unorderedElements.where( + (task) => task.group == group && task is DownloadTask, + ); + _pausedTaskIds.addAll(tasksToPause.map((e) => e.taskId)); + } + if (tasks != null) { + _pausedTaskIds.addAll(tasks.map((e) => e.taskId)); + } + } + } + + @override + Future resumeAll({Iterable? tasks, String? group}) async { + if (tasks == null && group == null) { + _paused = false; + _pausedTaskIds.clear(); + } else { + // resume specific tasks/groups + if (group != null) { + final tasksToResume = waiting.unorderedElements.where( + (task) => task.group == group && task is DownloadTask, + ); + for (final task in tasksToResume) { + _pausedTaskIds.remove(task.taskId); + } + } + if (tasks != null) { + for (final task in tasks) { + _pausedTaskIds.remove(task.taskId); + } + } + } + advanceQueue(); + } + + /// Add one [task] to the queue and advance the queue if possible + void add(Task task) { + waiting.add(task); + advanceQueue(); + } + + /// Add multiple [tasks] to the queue and advance the queue if possible + void addAll(Iterable tasks) { + waiting.addAll(tasks); + advanceQueue(); + } + + /// Remove all items in the queue. Does not affect tasks already enqueued + /// with the [FileDownloader] + void removeAll() => waiting.removeAll(); + + /// remove all waiting tasks matching [taskIds]. Does not affect tasks already enqueued + /// with the [FileDownloader] + void removeTasksWithIds(List taskIds) { + for (final taskId in taskIds) { + final match = waiting.unorderedElements.firstWhereOrNull( + (task) => task.taskId == taskId, + ); + if (match != null) { + waiting.remove(match); + } + } + } + + /// remove all waiting tasks in [group]. Does not affect tasks already enqueued + /// with the [FileDownloader] + void removeTasksWithGroup(String group) { + final tasksToRemove = waiting.unorderedElements + .where((task) => task.group == group) + .toList(growable: false); + for (final task in tasksToRemove) { + waiting.remove(task); + } + } + + /// Remove [task] from the queue. Does not affect tasks already enqueued + /// with the [FileDownloader] + void remove(Task task) => waiting.remove(task); + + /// Reset the state of the [TaskQueue]. + /// + /// Clears the [waiting] queue and resets active tasks to 0 + void reset({String? group}) { + if (group == null) { + removeAll(); + enqueued.clear(); + _activeByHost.clear(); + _activeByGroup.clear(); + } else { + removeTasksWithGroup(group); + final tasksToRemove = enqueued + .where((task) => task.group != group) + .toList(growable: false); + for (final task in tasksToRemove) { + if (enqueued.remove(task)) { + _decrementCounts(task); + } + } + } + } + + /// Advance the queue if possible and ready, no-op if not + /// + /// After the enqueue, [advanceQueue] is called again to ensure the + /// next item in the queue is enqueued, so the queue keeps going until + /// empty, or until it cannot enqueue another task + void advanceQueue() async { + if (_paused) { + return; + } + if (_readyForEnqueue.isCompleted) { + final task = getNextTask(); + if (task == null) { + return; + } + _readyForEnqueue = Completer(); + enqueued.add(task); + _incrementCounts(task); + enqueue(task).then((success) async { + if (!success) { + _log.warning( + 'TaskId ${task.taskId} did not enqueue successfully and will be ignored', + ); + if (_enqueueErrorsStreamController.hasListener) { + _enqueueErrorsStreamController.add(task); + } + } + await Future.delayed(minInterval); + _readyForEnqueue.complete(); + }); + _readyForEnqueue.future.then((_) => advanceQueue()); + } + } + + /// Get the next waiting task from the queue, or null if not available + Task? getNextTask() { + if (numActive >= maxConcurrent) { + return null; + } + final tasksThatHaveToWait = []; + while (waiting.isNotEmpty) { + final task = waiting.removeFirst(); + if (_pausedTaskIds.contains(task.taskId)) { + tasksThatHaveToWait.add(task); + continue; + } + if (numActiveWithHostname(task.hostName) < maxConcurrentByHost && + numActiveWithGroup(task.group) < maxConcurrentByGroup) { + waiting.addAll(tasksThatHaveToWait); // put back in queue + return task; + } + tasksThatHaveToWait.add(task); + } + waiting.addAll(tasksThatHaveToWait); // put back in queue + return null; + } + + /// Enqueue the task to the [FileDownloader] + /// + /// When using a [MemoryTaskQueue], do not use this method directly. Instead, + /// add your tasks to the queue using [add] and [addAll], and + /// let the [MemoryTaskQueue] manage the enqueueing. + Future enqueue(Task task) => FileDownloader().enqueue(task); + + /// Task has finished, so remove from active and advance the queue to the + /// next task if the task was indeed managed by this queue + @override + void taskFinished(Task task) { + if (enqueued.remove(task)) { + _decrementCounts(task); + advanceQueue(); + } + } + + /// Number of active tasks, i.e. enqueued with the FileDownloader and + /// not yet finished + int get numActive => enqueued.length; + + /// Returns number of tasks active with this host name + int numActiveWithHostname(String hostname) => _activeByHost[hostname] ?? 0; + + /// Returns number of tasks active with this group + int numActiveWithGroup(String group) => _activeByGroup[group] ?? 0; + + void _incrementCounts(Task task) { + try { + final host = task.hostName; + _activeByHost[host] = (_activeByHost[host] ?? 0) + 1; + } catch (_) { + // ignore invalid url + } + _activeByGroup[task.group] = (_activeByGroup[task.group] ?? 0) + 1; + } + + void _decrementCounts(Task task) { + try { + final host = task.hostName; + if (_activeByHost.containsKey(host)) { + final newCount = _activeByHost[host]! - 1; + if (newCount <= 0) { + _activeByHost.remove(host); + } else { + _activeByHost[host] = newCount; + } + } + } catch (_) { + // ignore invalid url + } + if (_activeByGroup.containsKey(task.group)) { + final newCount = _activeByGroup[task.group]! - 1; + if (newCount <= 0) { + _activeByGroup.remove(task.group); + } else { + _activeByGroup[task.group] = newCount; + } + } + } + + /// True if queue is empty + bool get isEmpty => waiting.isEmpty; + + /// Number of tasks waiting to be enqueued + int get numWaiting => waiting.length; + + /// Number of tasks waiting to be enqueued in [group] + int numWaitingWithGroup(String group) => waiting.unorderedElements + .where((element) => element.group == group) + .length; + + /// Stream with [Task]s that failed to enqueue correctly + Stream get enqueueErrors => _enqueueErrorsStreamController.stream; +} diff --git a/third_party/background_downloader/lib/src/task.dart b/third_party/background_downloader/lib/src/task.dart new file mode 100644 index 0000000..543cf49 --- /dev/null +++ b/third_party/background_downloader/lib/src/task.dart @@ -0,0 +1,1723 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' show Random; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:mime/mime.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'file_downloader.dart'; +import 'models.dart'; +import 'options/task_options.dart'; +import 'transfer_hint.dart'; +import 'uri/uri_helpers.dart'; +import 'utils.dart'; +import 'web_downloader.dart' + if (dart.library.io) 'desktop/desktop_downloader.dart'; + +part 'uri/uri_task.dart'; + +final _log = Logger('FileDownloader'); +final _random = Random(); + +/// A server Request +/// +/// An equality test on a [Request] is an equality test on the [url] +base class Request { + /// List of valid HTTP methods + final validHttpMethods = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH']; + + /// String representation of the url, urlEncoded + final String url; + + /// potential additional headers to send with the request + final Map headers; + + /// HTTP request method to use + final String httpRequestMethod; + + /// Set [post] to make the request using POST instead of GET. + /// In the constructor, [post] must be one of the following: + /// - a String: POST request with [post] as the body, encoded in utf8 + /// - a Map: will be jsonEncoded to a String and set as the POST body + /// - a List of bytes: will be converted to a String using String.fromCharCodes + /// and set as the POST body + /// - a List: map will be jsonEncoded to a String and set as the POST body + /// + /// The field [post] will be a String. If the constructor was given a list + /// of bytes, [postIsBinary] is true and each character of [post] represents + /// one byte, which is sent as-is (not utf8 encoded) + final String? post; + + /// True if [post] holds binary data, one byte per character, that must be + /// sent without utf8 encoding + final bool postIsBinary; + + /// Maximum number of retries the downloader should attempt + /// + /// Defaults to 0, meaning no retry will be attempted + final int retries; + + /// Number of retries remaining + int retriesRemaining; + + /// Time at which this request was first created + final DateTime creationTime; + + /// Creates a [Request] + /// + /// [url] must not be encoded and can include query parameters + /// [urlQueryParameters] may be added and will be appended to the [url] + /// [headers] an optional map of HTTP request headers + /// [post] if set, uses POST instead of GET. Post must be one of the + /// following: + /// - a String: POST request with [post] as the body, encoded in utf8 + /// - a Map: will be jsonEncoded to a String and set as the POST body + /// - a List of bytes: will be converted to a String using String.fromCharCodes + /// and set as the POST body + /// - a List: map will be jsonEncoded to a String and set as the POST body + /// + /// [retries] if >0 will retry a failed download this many times + Request({ + required String url, + Map? urlQueryParameters, + Map? headers, + String? httpRequestMethod, + post, + this.retries = 0, + DateTime? creationTime, + }) : url = urlWithQueryParameters(url, urlQueryParameters), + headers = headers ?? {}, + httpRequestMethod = + httpRequestMethod?.toUpperCase() ?? (post == null ? 'GET' : 'POST'), + post = post is Uint8List + ? String.fromCharCodes(post) + : post is Map || post is List + ? jsonEncode(post) + : post, + postIsBinary = post is Uint8List, + retriesRemaining = retries, + creationTime = creationTime ?? DateTime.now() { + if (retries < 0 || retries > 10) { + throw ArgumentError('Number of retries must be in range 1 through 10'); + } + if (!validHttpMethods.contains(this.httpRequestMethod)) { + throw ArgumentError( + 'Invalid httpRequestMethod "${this.httpRequestMethod}": Must be one of ${validHttpMethods.join(', ')}', + ); + } + } + + /// Creates object from [json] + Request.fromJson(Map json) + : url = json['url'] ?? '', + headers = Map.from(json['headers'] ?? {}), + httpRequestMethod = + json['httpRequestMethod'] as String? ?? + (json['post'] == null ? 'GET' : 'POST'), + post = json['post'] as String?, + postIsBinary = json['postIsBinary'] as bool? ?? false, + retries = (json['retries'] as num?)?.toInt() ?? 0, + retriesRemaining = (json['retriesRemaining'] as num?)?.toInt() ?? 0, + creationTime = DateTime.fromMillisecondsSinceEpoch( + (json['creationTime'] as num?)?.toInt() ?? 0, + ); + + /// Creates JSON map of this object + Map toJson() => { + 'url': url, + 'headers': headers, + 'httpRequestMethod': httpRequestMethod, + 'post': post, + if (postIsBinary) + 'postIsBinary': true, // omitted if false, for compatibility + 'retries': retries, + 'retriesRemaining': retriesRemaining, + 'creationTime': creationTime.millisecondsSinceEpoch, + }; + + /// The [post] body as raw bytes if [postIsBinary], otherwise the [post] String. + /// + /// Use this when sending the body, or when passing [post] on to a copy of + /// this [Request], so that binary data is not utf8 encoded + Object? get postBody => + postIsBinary ? Uint8List.fromList(post?.codeUnits ?? []) : post; + + /// The regex pattern to split the cookies in `Set-Cookie`. + static final _splitSetCookiesRegExp = RegExp(',(?=[^ ])'); + + /// Returns the cookie header appropriate for this [Request], + /// taken from the [cookies] list. + /// + /// [cookies] can be a `List` or the 'Set-Cookie' header value + /// + /// The returned map is the 'Cookie:' header, with the + /// value=name; value2=name2 as the value. + static Map cookieHeader(dynamic cookies, String url) { + final Uri uri; + try { + uri = Uri.parse(url); + } catch (e) { + _log.fine('Invalid url: $url error: $e'); + return {}; + } + final List cookieList = switch (cookies) { + final http.Response response => cookiesFromSetCookie( + response.headers['set-cookie'] ?? '', + ), + final List list => list, + String _ => cookiesFromSetCookie(cookies), + _ => throw ArgumentError( + 'cookies parameter must be a http.Response object, a String or a List', + ), + }; + final path = uri.path.isNotEmpty ? uri.path : '/'; + final validCookies = cookieList.where( + (cookie) => + (cookie.maxAge == null || cookie.maxAge! > 0) && + (cookie.domain == null || + uri.host.endsWith(cookie.domain!) || + (cookie.domain!.startsWith('.') && + uri.host == cookie.domain!.substring(1))) && + (cookie.path == null || path.startsWith(cookie.path!)) && + (cookie.expires == null || cookie.expires!.isAfter(DateTime.now())) && + (!cookie.secure || uri.scheme == 'https'), + ); + final cookieHeaderValue = validCookies + .map((c) => c.name.isNotEmpty ? '${c.name}=${c.value}' : c.value) + .join('; '); + return cookieHeaderValue.isNotEmpty ? {'Cookie': cookieHeaderValue} : {}; + } + + /// Returns a list of Cookies extracted from the [setCookie] string, + /// which is the value of the 'Set-Cookie' header of a server response + /// + /// Based on https://github.com/dart-lang/http/pull/688/files + static List cookiesFromSetCookie(String setCookie) { + final cookies = []; + if (setCookie.isNotEmpty) { + for (final cookie in setCookie.split(_splitSetCookiesRegExp)) { + cookies.add(Cookie.fromSetCookieValue(cookie)); + } + } + return cookies; + } + + /// Decrease [retriesRemaining] by one + void decreaseRetriesRemaining() => retriesRemaining--; + + /// Hostname represented by the url. Throws [FormatException] if url cannot + /// be parsed, and returns empty string if no host in url + String get hostName => Uri.parse(url).host; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Request && runtimeType == other.runtimeType && url == other.url; + + @override + int get hashCode => url.hashCode; + + @override + String toString() => + 'Request{url: $url, headers: $headers, httpRequestMethod: ' + '$httpRequestMethod, post: ${post == null ? "null" : "not null"}, ' + 'retries: $retries, retriesRemaining: $retriesRemaining}'; +} + +/// RegExp to match a path separator and root directory +final _pathSeparatorRegExp = RegExp(r'[/\\]'); +final _startsWithPathSeparatorRegExp = RegExp(r'^[/\\]'); +final _rootDirectoryRegExp = RegExp(r'^(/|\\|([a-zA-Z]:[\\/]))'); + +/// Information related to a [Task] +/// +/// A [Task] is the base class for [DownloadTask] and +/// [UploadTask] +/// +/// An equality test on a [Task] is a test on the [taskId] +/// only - all other fields are ignored in that test +sealed class Task extends Request implements Comparable { + /// Identifier for the task - auto generated if omitted + final String taskId; + + /// Filename of the file to store - use {filename} in notification + final String filename; + + /// Optional directory, relative to the base directory + final String directory; + + /// Base directory + final BaseDirectory baseDirectory; + + /// Group that this task belongs to + final String group; + + /// Type of progress updates desired + final Updates updates; + + /// If true, task is restricted to non-metered / non-cellular connections + /// (on Android, requires an unmetered network connection; on iOS, disables cellular access). + final bool requiresWiFi; + + /// If true, task will pause if the task fails partly through the execution, + /// when some but not all bytes have transferred, provided the server supports + /// partial transfers. Such failures are typically temporary, eg due to + /// connectivity issues, and may be resumed when connectivity returns. + /// If false, task fails on any issue, and task cannot be paused + final bool allowPause; + + /// Priority of this task, relative to other tasks. + /// Range 0 <= priority <= 10 with 0 being the highest priority. + /// Not all platforms will have the same actual granularity, and how + /// priority is considered is inconsistent across platforms. + final int priority; + + /// User-defined metadata - use {metaData} in notification + final String metaData; + + /// Human readable name for this task - use {displayName} in notification + final String displayName; + + /// Optional task-specific configuration using [TaskOptions] + final TaskOptions? options; + + /// Optional hints provided to tune the task's properties + final Set? transferHints; + + /// Optional notification configuration associated with this task + final TaskNotificationConfig? notificationConfig; + + /// Optional stall timeout. If running and no progress is observed within this duration, + /// the transfer is restarted/resumed. + final Duration? stallTimeout; + + static bool useExternalStorage = false; // for Android configuration only + + static final _baseDirectoryPathCache = <(BaseDirectory, bool), String>{}; + + static const _androidBaseDirs = [ + BaseDirectory.temporary, + BaseDirectory.applicationLibrary, + BaseDirectory.applicationSupport, + BaseDirectory.applicationDocuments, + ]; + + static const _otherBaseDirs = [ + BaseDirectory.temporary, + BaseDirectory.applicationSupport, + BaseDirectory.applicationLibrary, + BaseDirectory.applicationDocuments, + ]; + + /// Uri schemes that are supported by the downloader in the context of + /// a [Task] download directory or upload file + static const allowedUriSchemes = ['file', 'content', 'urlbookmark', 'media']; + + /// Creates a [Task] + /// + /// [taskId] must be unique. A unique id will be generated if omitted + /// [url] properly encoded if necessary, can include query parameters + /// [urlQueryParameters] may be added and will be appended to the [url], must + /// be properly encoded if necessary + /// [filename] of the file to save. If omitted, a random filename will be + /// generated + /// [headers] an optional map of HTTP request headers + /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) + /// [post] if set, uses POST instead of GET. Post must be one of the + /// following: + /// - a String: POST request with [post] as the body, encoded in utf8 + /// - a Map: will be jsonEncoded to a String and set as the POST body + /// - a List of bytes: will be converted to a String using String.fromCharCodes + /// and set as the POST body + /// - a List: map will be jsonEncoded to a String and set as the POST body + /// [directory] optional directory name, precedes [filename] + /// [baseDirectory] one of the base directories, precedes [directory] + /// [group] if set allows different callbacks or processing for different + /// groups + /// [updates] the kind of progress updates requested + /// [requiresWiFi] if set, restricts task to non-metered / non-cellular connections + /// (on Android API 28+, requires a Wi-Fi connection; on older Android versions, requires an unmetered connection; on iOS, disables cellular access). + /// If not set, task may execute over cellular or metered networks. + /// [retries] if >0 will retry a failed download this many times + /// [allowPause] + /// If true, task will pause if the task fails partly through the execution, + /// when some but not all bytes have transferred, provided the server supports + /// partial transfers. Such failures are typically temporary, eg due to + /// connectivity issues, and may be resumed when connectivity returns + /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5. + /// On Android 14+, setting priority to 0 requires the + /// `android.permission.RUN_USER_INITIATED_JOBS` permission in AndroidManifest.xml. + /// If the permission is missing, the downloader will automatically fall back to normal operation (which may include a foreground service if configured). + /// [metaData] user data + /// [displayName] human readable name for this task + /// [creationTime] time of task creation, 'now' by default. + /// [options] optional task-specific configuration using [TaskOptions] + /// [transferHints] optional hints to auto-tune task priority, updates, and pause behavior + /// [notificationConfig] optional system notification configuration + /// [stallTimeout] optional duration after which an inactive running transfer is kicked/resumed + Task({ + String? taskId, + required super.url, + super.urlQueryParameters, + String? filename, + super.headers, + super.httpRequestMethod, + super.post, + String directory = '', + this.baseDirectory = BaseDirectory.applicationDocuments, + this.group = 'default', + Updates updates = Updates.status, + this.requiresWiFi = false, + super.retries, + this.metaData = '', + this.displayName = '', + bool allowPause = false, + int priority = 5, + super.creationTime, + this.options, + this.transferHints, + this.notificationConfig, + this.stallTimeout, + }) : taskId = taskId ?? _random.nextInt(1 << 32).toString(), + filename = + filename ?? + ((transferHints?.contains(TransferHint.useSuggestedFilename) == true) + ? DownloadTask.suggestedFilename + : _random.nextInt(1 << 32).toString()), + directory = _startsWithPathSeparatorRegExp.hasMatch(directory) + ? directory.substring(1) + : directory, + updates = (transferHints?.contains(TransferHint.smallFile) == true) + ? Updates.status + : updates, + allowPause = + allowPause || + (transferHints?.contains(TransferHint.userInitiated) == true) || + (transferHints?.contains(TransferHint.largeFile) == true), + priority = + (transferHints?.contains(TransferHint.userInitiated) == true && + priority == 5) + ? 0 + : (transferHints?.contains(TransferHint.lowPriority) == true && + priority == 5) + ? 10 + : priority { + if (filename?.isEmpty == true) { + throw ArgumentError('Filename cannot be empty'); + } + if (this is! UriTask && + this is! MultiUploadTask && + _pathSeparatorRegExp.hasMatch(this.filename)) { + throw ArgumentError('Filename cannot contain path separators'); + } + if (allowPause && post != null) { + throw ArgumentError('Tasks that can pause must be GET requests'); + } + if (priority < 0 || priority > 10) { + throw ArgumentError('Priority must be 0 <= priority <= 10'); + } + } + + /// Create a new [Task] subclass from the provided [json] + /// + /// Use [Task.createFromJson] to create a properly subclassed [Task] from the [json] + factory Task.createFromJson(Map json) => + switch (json['taskType']) { + 'DownloadTask' => DownloadTask.fromJson(json), + 'UriDownloadTask' => UriDownloadTask.fromJson(json), + 'UploadTask' => UploadTask.fromJson(json), + 'UriUploadTask' => UriUploadTask.fromJson(json), + 'MultiUploadTask' => MultiUploadTask.fromJson(json), + 'ParallelDownloadTask' => ParallelDownloadTask.fromJson(json), + 'DataTask' => DataTask.fromJson(json), + _ => throw ArgumentError( + 'taskType not in [DownloadTask, UploadTask, MultiUploadTask, ParallelDownloadTask, DataTask]', + ), + }; + + /// Create a new [Task] subclass from provided [jsonString] + factory Task.createFromJsonString(String jsonString) => + Task.createFromJson(jsonDecode(jsonString)); + + /// Returns the absolute path to the file represented by this task + /// based on the [Task.filename] (default) or [withFilename] + /// + /// If the task is a MultiUploadTask and no [withFilename] is given, + /// returns the empty string, as there is no single path that can be + /// returned. + /// + /// Throws a FileSystemException if using external storage on Android (via + /// configuration at startup), and external storage is not available. + /// + /// If called when the task is a [UriTask], will return the filePath of a file + /// scheme Uri, but may throw if unavailable or the scheme is not 'file' + Future filePath({String? withFilename}) async { + if (this is MultiUploadTask && withFilename == null) { + return ''; + } + switch (this) { + case final UriTask t: + if (t.fileUri != null) { + assert( + t.fileUri?.scheme == 'file', + 'fileUri must be a URI scheme to return a path', + ); + return t.fileUri!.toFilePath( + windows: defaultTargetPlatform == TargetPlatform.windows, + ); + } else { + assert( + t.directoryUri?.scheme == 'file', + 'directoryUri must be a URI scheme to return a path', + ); + return '${t.directoryUri!.toFilePath(windows: defaultTargetPlatform == TargetPlatform.windows)}${Platform.pathSeparator}${withFilename ?? filename}'; + } + default: + return p.join( + await baseDirectoryPath(baseDirectory), + directory, + withFilename ?? filename, + ); + } + } + + /// Returns the path to the directory represented by [baseDirectory] + /// + /// On Windows, if [baseDirectory] is .root, returns the empty string + /// because the drive letter is required to be included in the directory + /// path + static Future baseDirectoryPath(BaseDirectory baseDirectory) async { + final cacheKey = (baseDirectory, Task.useExternalStorage); + if (_baseDirectoryPathCache.containsKey(cacheKey)) { + return _baseDirectoryPathCache[cacheKey]!; + } + Directory? externalStorageDirectory; + Directory? externalCacheDirectory; + if (Task.useExternalStorage) { + externalStorageDirectory = await getExternalStorageDirectory(); + externalCacheDirectory = (await getExternalCacheDirectories())?.first; + if (externalStorageDirectory == null || externalCacheDirectory == null) { + throw const FileSystemException( + 'Android external storage is not available', + ); + } + } + final baseDir = switch ((baseDirectory, Task.useExternalStorage)) { + (.applicationDocuments, false) => + await getApplicationDocumentsDirectory(), + (.temporary, false) => await getTemporaryDirectory(), + (.applicationSupport, false) => await getApplicationSupportDirectory(), + (.applicationLibrary, false) + when defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS => + await getLibraryDirectory(), + (.applicationLibrary, false) => Directory( + p.join((await getApplicationSupportDirectory()).path, 'Library'), + ), + (.root, _) => Directory('/'), + // Android only: external storage variants + (.applicationDocuments, true) => externalStorageDirectory!, + (.temporary, true) => externalCacheDirectory!, + (.applicationSupport, true) => Directory( + p.join(externalStorageDirectory!.path, 'Support'), + ), + (.applicationLibrary, true) => Directory( + p.join(externalStorageDirectory!.path, 'Library'), + ), + }; + final path = + (defaultTargetPlatform == TargetPlatform.windows && + baseDirectory == BaseDirectory.root) + ? '' + : baseDir.absolute.path; + _baseDirectoryPathCache[cacheKey] = path; + return path; + } + + /// Extract the baseDirectory, directory and filename from + /// the provided [filePath] or [file], and return this as a record + /// + /// Either [filePath] or [file] must be provided, not both. + /// + /// Throws a FileSystemException if using external storage on Android (via + /// configuration at startup), and external storage is not available. + static Future< + (BaseDirectory baseDirectory, String directory, String filename) + > + split({String? filePath, File? file}) async { + assert( + (filePath != null) ^ (file != null), + 'Either filePath or file must be given and not both', + ); + final path = filePath ?? file!.absolute.path; + final absoluteDirectoryPath = p.dirname(path); + final filename = p.basename(path); + // try to match the start of the absoluteDirectory to one of the + // directories represented by the BaseDirectory enum. + // Order matters, as some may be subdirs of others + final testSequence = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.windows + ? _androidBaseDirs + : _otherBaseDirs; + for (final baseDirectoryEnum in testSequence) { + final baseDirPath = await baseDirectoryPath(baseDirectoryEnum); + final (match, directory) = _contains(baseDirPath, absoluteDirectoryPath); + if (match) { + return (baseDirectoryEnum, directory, filename); + } + } + // if no match, return a BaseDirectory.root with the absoluteDirectory + // minus the leading characters that designate the root (differs by platform) + final match = _rootDirectoryRegExp.firstMatch(absoluteDirectoryPath); + return ( + BaseDirectory.root, + absoluteDirectoryPath.substring(match?.end ?? 0), + filename, + ); + } + + /// Returns the subdirectory of the given [baseDirPath] within [dirPath], + /// if [dirPath] starts with [baseDirPath]. + /// + /// If found, returns (true, subdir) otherwise returns (false, ''). + /// + /// [dirPath] should not contain a filename - if it does, it is returned + /// as part of the subdir. + static (bool, String) _contains(String baseDirPath, String dirPath) { + final separator = Platform.pathSeparator; + final basePath = baseDirPath.length > 1 && baseDirPath.endsWith(separator) + ? baseDirPath.substring(0, baseDirPath.length - 1) + : baseDirPath; + // baseDirPath must be followed by a separator or the end of dirPath + final match = RegExp( + '^${RegExp.escape(basePath)}(?:${RegExp.escape(separator)}(.*))?\$', + ).firstMatch(dirPath); + return (match != null, match?.group(1) ?? ''); + } + + /// Returns a copy of the [Task] with optional changes to specific fields + Task copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? retries, + int? retriesRemaining, + bool? allowPause, + int? priority, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }); + + /// Creates [Task] object from JsonMap + /// + /// Only used by subclasses. Use [Task.createFromJson] to create a properly + /// subclassed [Task] from the [json] + Task.fromJson(super.json) + : taskId = json['taskId'] ?? '', + filename = json['filename'] ?? '', + directory = json['directory'] ?? '', + baseDirectory = + BaseDirectory.values[(json['baseDirectory'] as num?)?.toInt() ?? 0], + group = json['group'] ?? FileDownloader.defaultGroup, + updates = Updates.values[(json['updates'] as num?)?.toInt() ?? 0], + requiresWiFi = json['requiresWiFi'] ?? false, + allowPause = json['allowPause'] ?? false, + priority = (json['priority'] as num?)?.toInt() ?? 5, + metaData = json['metaData'] ?? '', + displayName = json['displayName'] ?? '', + options = json['options'] != null + ? TaskOptions.fromJson(json['options']) + : null, + transferHints = json['transferHints'] != null + ? (json['transferHints'] as List) + .map((e) => TransferHint.values[(e as num).toInt()]) + .toSet() + : null, + notificationConfig = null, + stallTimeout = json['stallTimeout'] != null + ? Duration(milliseconds: (json['stallTimeout'] as num).toInt()) + : null, + super.fromJson(); + + /// Creates JSON map of this object + @override + Map toJson() => { + ...super.toJson(), + 'taskId': taskId, + 'filename': filename, + 'directory': directory, + 'baseDirectory': baseDirectory.index, // stored as int + 'group': group, + 'updates': updates.index, // stored as int + 'requiresWiFi': requiresWiFi, + 'allowPause': allowPause, + 'priority': priority, + 'metaData': metaData, + 'displayName': displayName, + 'options': options?.toJson(), + if (transferHints != null) + 'transferHints': transferHints!.map((h) => h.index).toList(), + if (stallTimeout != null) 'stallTimeout': stallTimeout!.inMilliseconds, + 'taskType': taskType, + }; + + /// If true, task expects progress updates + bool get providesProgressUpdates => + updates == Updates.progress || updates == Updates.statusAndProgress; + + /// If true, task expects status updates + bool get providesStatusUpdates => + updates == Updates.status || updates == Updates.statusAndProgress; + + /// Returns the type of task as a String + /// + /// Used to identify the task type in JSON format + String get taskType => 'Task'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Task && + runtimeType == other.runtimeType && + taskId == other.taskId; + + @override + int get hashCode => taskId.hashCode; + + /// Returns this.priority - other.priority if not the same + /// Returns this.creationTime - other.creationTime if priorities the same + /// Returns 0 if other is not a [Task] + @override + int compareTo(other) { + if (other is Task) { + final diff = priority - other.priority; + if (diff != 0) { + return diff; + } + return creationTime.difference(other.creationTime).inMicroseconds; + } + return 0; + } + + @override + String toString() => + '$taskType{taskId: $taskId, url: $url, filename: $filename, headers: ' + '$headers, httpRequestMethod: $httpRequestMethod, post: ${post == null ? "null" : "not null"}, directory: $directory, baseDirectory: $baseDirectory, group: $group, updates: $updates, requiresWiFi: $requiresWiFi, retries: $retries, retriesRemaining: $retriesRemaining, allowPause: $allowPause, priority: $priority, metaData: $metaData, displayName: $displayName}'; +} + +/// Information related to a download task +/// +/// An equality test on a [DownloadTask] is a test on the [taskId] +/// only - all other fields are ignored in that test +final class DownloadTask extends Task { + /// Creates a [DownloadTask] + /// + /// [taskId] must be unique. A unique id will be generated if omitted + /// [url] properly encoded if necessary, can include query parameters + /// [urlQueryParameters] may be added and will be appended to the [url], must + /// be properly encoded if necessary + /// [filename] of the file to save. If omitted, a random filename will be + /// generated + /// [headers] an optional map of HTTP request headers + /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) + /// [post] if set, uses POST instead of GET. Post must be one of the + /// following: + /// - a String: POST request with [post] as the body, encoded in utf8 + /// - a Map: will be jsonEncoded to a String and set as the POST body + /// - a List of bytes: will be converted to a String using String.fromCharCodes + /// and set as the POST body + /// - a List: map will be jsonEncoded to a String and set as the POST body + /// + /// [directory] optional directory name, precedes [filename] + /// [baseDirectory] one of the base directories, precedes [directory] + /// [group] if set allows different callbacks or processing for different + /// groups + /// [updates] the kind of progress updates requested + /// [requiresWiFi] if set, restricts task to non-metered / non-cellular connections + /// (on Android API 28+, requires a Wi-Fi connection; on older Android versions, requires an unmetered connection; on iOS, disables cellular access). + /// If not set, task may execute over cellular or metered networks. + /// [retries] if >0 will retry a failed download this many times + /// [allowPause] if true, allows pause command + /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5. + /// On Android 14+, setting priority to 0 requires the + /// `android.permission.RUN_USER_INITIATED_JOBS` permission in AndroidManifest.xml. + /// If the permission is missing, the downloader will automatically fall back to normal operation (which may include a foreground service if configured). + /// [metaData] user data + /// [displayName] human readable name for this task + /// [creationTime] time of task creation, 'now' by default. + /// [options] optional task-specific configuration using [TaskOptions] + /// [transferHints] optional hints to auto-tune task priority, updates, and pause behavior + /// [notificationConfig] optional system notification configuration + /// [stallTimeout] optional duration after which an inactive running transfer is kicked/resumed + DownloadTask({ + super.taskId, + required super.url, + super.urlQueryParameters, + super.filename, + super.headers, + super.httpRequestMethod, + super.post, + super.directory, + super.baseDirectory, + super.group, + super.updates, + super.requiresWiFi, + super.retries, + super.allowPause, + super.priority, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }); + + /// List of task types supported by [DownloadTask.fromJson] + static final _taskTypes = [ + 'DownloadTask', + 'ParallelDownloadTask', + 'UriDownloadTask', + ]; + + /// Creates [DownloadTask] object from [json] + DownloadTask.fromJson(super.json) + : assert( + _taskTypes.contains(json['taskType']), + 'The provided JSON map is not an UploadTask, ' + 'because key "taskType" is not in ${_taskTypes.join(',')}', + ), + super.fromJson(); + + @override + String get taskType => 'DownloadTask'; + + @override + DownloadTask copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? retries, + int? retriesRemaining, + bool? allowPause, + int? priority, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => DownloadTask( + taskId: taskId ?? this.taskId, + url: url ?? this.url, + filename: filename ?? this.filename, + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + post: post ?? postBody, + directory: directory ?? this.directory, + baseDirectory: baseDirectory ?? this.baseDirectory, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + retries: retries ?? this.retries, + allowPause: allowPause ?? this.allowPause, + priority: priority ?? this.priority, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; + + /// Returns a copy of the task with the [Task.filename] property changed + /// to the filename suggested by the server, or derived from the url, or + /// unchanged. + /// + /// If [unique] is true, the filename is guaranteed not to already exist. This + /// is accomplished by adding a suffix to the suggested filename with a number, + /// e.g. "data (2).txt" + /// If a [taskWithFilenameBuilder] is supplied, this is the function called to + /// convert the task, response headers and [unique] values into a new [DownloadTask] + /// with the suggested filename. By default, [taskWithSuggestedFilename] is used, + /// which parses the Content-Disposition according to RFC6266, or takes the last + /// path segment of the URL, or leaves the filename unchanged + /// + /// The suggested filename is obtained by making a HEAD request to the url + /// represented by the [DownloadTask], including urlQueryParameters and headers + Future withSuggestedFilename({ + bool unique = false, + Future Function( + DownloadTask task, + Map headers, + bool unique, + ) + taskWithFilenameBuilder = + taskWithSuggestedFilename, + }) async { + try { + final response = await DesktopDownloader.httpClient.head( + Uri.parse(url), + headers: headers, + ); + if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) { + return await taskWithFilenameBuilder(this, response.headers, unique); + } + } catch (e) { + _log.finer('Error connecting to server'); + } + return taskWithFilenameBuilder(this, {}, unique); + } + + /// Return the expected file size for this task, or -1 if unknown + /// + /// The expected file size is obtained by making a HEAD request to the url + /// represented by the [DownloadTask], including urlQueryParameters and headers + Future expectedFileSize() async { + try { + final response = await DesktopDownloader.httpClient.head( + Uri.parse(url), + headers: headers, + ); + if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) { + return getContentLength(response.headers, this); + } + } catch (e) { + // no content length available + } + return -1; + } + + /// Constant used with `filename` field to indicate server suggestion requested + static const suggestedFilename = '?'; + + /// True if this task has a filename, or false if set to `suggest` + bool get hasFilename => filename != suggestedFilename; +} + +/// Information related to an upload task +/// +/// An equality test on a [UploadTask] is a test on the [taskId] +/// only - all other fields are ignored in that test +final class UploadTask extends Task { + /// Name of the field used for multi-part file upload + final String fileField; + + /// mimeType of the file to upload + final String mimeType; + + /// Map of name/value pairs to encode as form fields in a multi-part upload. + /// To specify multiple values for a single name, format the value as + /// '"value1", "value2", "value3"' so that it matches the following + /// RegEx: `^(?:"[^"]+"\s*,\s*)+"[^"]+"$` + final Map fields; + + /// Creates [UploadTask] + /// + /// [taskId] must be unique. A unique id will be generated if omitted + /// [url] properly encoded if necessary, can include query parameters + /// [urlQueryParameters] may be added and will be appended to the [url], must + /// be properly encoded if necessary + /// [filename] of the file to upload + /// [headers] an optional map of HTTP request headers + /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) + /// [post] if set to 'binary' will upload as binary file, otherwise multi-part + /// [fileField] for multi-part uploads, name of the file field or 'file' by + /// default + /// [mimeType] the mimeType of the file, or derived from filename extension + /// by default + /// [fields] for multi-part uploads, optional map of name/value pairs to upload + /// along with the file as form fields + /// [directory] optional directory name, precedes [filename] + /// [baseDirectory] one of the base directories, precedes [directory] + /// [group] if set allows different callbacks or processing for different + /// groups + /// [updates] the kind of progress updates requested + /// [requiresWiFi] if set, restricts task to non-metered / non-cellular connections + /// (on Android API 28+, requires a Wi-Fi connection; on older Android versions, requires an unmetered connection; on iOS, disables cellular access). + /// If not set, task may execute over cellular or metered networks. + /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5. + /// On Android 14+, setting priority to 0 requires the + /// `android.permission.RUN_USER_INITIATED_JOBS` permission in AndroidManifest.xml. + /// If the permission is missing, the downloader will automatically fall back to normal operation (which may include a foreground service if configured). + /// [retries] if >0 will retry a failed upload this many times + /// [metaData] user data + /// [displayName] human readable name for this task + /// [creationTime] time of task creation, 'now' by default. + /// [options] optional task-specific configuration using [TaskOptions] + UploadTask({ + super.taskId, + required super.url, + super.urlQueryParameters, + required String super.filename, + super.headers, + String? httpRequestMethod, + String? post, + this.fileField = 'file', + String? mimeType, + Map? fields, + super.directory, + super.baseDirectory, + super.group, + super.updates, + super.requiresWiFi, + super.retries, + super.priority, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : assert(filename.isNotEmpty, 'A filename is required'), + assert( + (post ?? + (transferHints?.contains(TransferHint.binaryUpload) == true + ? 'binary' + : null)) == + null || + (post ?? + (transferHints?.contains(TransferHint.binaryUpload) == true + ? 'binary' + : null)) == + 'binary', + 'post field must be null, or "binary" for binary file upload', + ), + assert( + fields == null || + fields.isEmpty || + (post ?? + (transferHints?.contains(TransferHint.binaryUpload) == true + ? 'binary' + : null)) != + 'binary', + 'fields only allowed for multi-part uploads', + ), + fields = fields ?? {}, + mimeType = + mimeType ?? lookupMimeType(filename) ?? 'application/octet-stream', + super( + post: + post ?? + (transferHints?.contains(TransferHint.binaryUpload) == true + ? 'binary' + : null), + httpRequestMethod: httpRequestMethod ?? 'POST', + allowPause: false, + ); + + /// Creates [UploadTask] from a [File] object, using the [file] absolute path. + /// + /// Note that using absolute paths is discouraged on mobile, as the path to + /// files in an application's directory scope is not stable between application + /// starts. Use the combination of [baseDirectory], [directory] and [filename] + /// whenever possible to prevent hard to debug errors. + UploadTask.fromFile({ + required File file, + super.taskId, + required super.url, + super.urlQueryParameters, + super.headers, + String? httpRequestMethod, + String? post, + this.fileField = 'file', + String? mimeType, + Map? fields, + super.group, + super.updates, + super.requiresWiFi, + super.retries, + super.priority, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : fields = fields ?? {}, + mimeType = + mimeType ?? lookupMimeType(file.path) ?? 'application/octet-stream', + super( + baseDirectory: BaseDirectory.root, + directory: p.dirname(file.absolute.path), + filename: p.basename(file.absolute.path), + post: + post ?? + (transferHints?.contains(TransferHint.binaryUpload) == true + ? 'binary' + : null), + httpRequestMethod: httpRequestMethod ?? 'POST', + allowPause: false, + ); + + /// List of task types supported by [UploadTask.fromJson] + static final _taskTypes = ['UploadTask', 'MultiUploadTask', 'UriUploadTask']; + + /// Creates [UploadTask] object from [json] + UploadTask.fromJson(super.json) + : assert( + _taskTypes.contains(json['taskType']), + 'The provided JSON map is not an UploadTask, ' + 'because key "taskType" is not in ${_taskTypes.join(',')}', + ), + fileField = json['fileField'] ?? 'file', + mimeType = json['mimeType'] ?? 'application/octet-stream', + fields = Map.from(json['fields'] ?? {}), + super.fromJson(); + + /// Returns a list of fileData elements, one for each file to upload. + /// Each element is a triple containing fileField, full filePath, mimeType + /// + /// The lists are stored in the similarly named String fields as a JSON list, + /// with each list the same length. For the filenames list, if a filename refers + /// to a file that exists (i.e. it is a full path) then that is the filePath used, + /// otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory] + /// to form a full file path. If the filename is represented as a file:// uri + /// then that Uri's filePath is used + Future> extractFilesData() async { + final List fileFields = List.from(jsonDecode(fileField)); + final List filenames = List.from(jsonDecode(filename)); + final List mimeTypes = List.from(jsonDecode(mimeType)); + final result = <(String, String, String)>[]; + for (int i = 0; i < fileFields.length; i++) { + final fileUri = Uri.tryParse(filenames[i]); + final filenameOrPath = (fileUri?.scheme == 'file') + ? fileUri!.toFilePath( + windows: defaultTargetPlatform == TargetPlatform.windows, + ) + : filenames[i]; + final file = File(filenameOrPath); + if (await file.exists()) { + result.add((fileFields[i], filenameOrPath, mimeTypes[i])); + } else { + // if file does not exist, assume it is a filename only relative path) and + // append to baseDirectory and directory + result.add(( + fileFields[i], + await filePath(withFilename: filenameOrPath), + mimeTypes[i], + )); + } + } + return result; + } + + @override + Map toJson() => { + ...super.toJson(), + 'fileField': fileField, + 'mimeType': mimeType, + 'fields': fields, + }; + + @override + String get taskType => 'UploadTask'; + + @override + UploadTask copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? fileField, + String? mimeType, + Map? fields, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? retries, + int? retriesRemaining, + bool? allowPause, + int? priority, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => UploadTask( + taskId: taskId ?? this.taskId, + url: url ?? this.url, + filename: filename ?? this.filename, + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + post: post as String? ?? this.post, + fileField: fileField ?? this.fileField, + mimeType: mimeType ?? this.mimeType, + fields: fields ?? this.fields, + directory: directory ?? this.directory, + baseDirectory: baseDirectory ?? this.baseDirectory, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + priority: priority ?? this.priority, + retries: retries ?? this.retries, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; + + @override + String toString() => + '${super.toString()} and fileField $fileField, ' + 'mimeType $mimeType and fields $fields'; +} + +/// Information related to an UploadTask, containing multiple files to upload +/// +/// An equality test on a [UploadTask] is a test on the [taskId] +/// only - all other fields are ignored in that test +/// +/// A [MultiUploadTask] is initialized with a list representing the files to upload. +/// Each element is either a filename/path/Uri, or a (fileField, filename/path/Uri), +/// or a (fileField, filename/path/Uri, mimeType). +/// When instantiating a [MultiUploadTask], this list is converted into +/// three lists: [fileFields], [filenames], and [mimeTypes], available +/// as fields. These lists are also encoded to a JSON string representation in +/// the fields [fileField], [filename] and [mimeType],so - different from +/// a single [UploadTask] - these fields now contain a JSON object representing all +/// files. +/// filename/path/Uri means either a filename without directory (and the +/// directory will be based on the [Task.baseDirectory] and [Task.directory] +/// fields), or you specify a full file path or a Uri (which will be converted +/// to a Uri string). For example: "hello.txt" or +/// "/data/com.myapp/data/dir/hello.txt" or "content://some/path/to/file.txt" +final class MultiUploadTask extends UploadTask { + final List fileFields, filenames, mimeTypes; + + static const _filesArgumentError = + 'files must be a list of filenames, or a list of records of type ' + '(fileField, filename) or (fileField, filename, mimeType)'; + + /// Creates [MultiUploadTask] to upload more than one file using + /// multipart/form-data + /// + /// [taskId] must be unique. A unique id will be generated if omitted + /// [url] properly encoded if necessary, can include query parameters + /// [urlQueryParameters] may be added and will be appended to the [url], must + /// be properly encoded if necessary + /// [files] list of objects representing each file to upload. If not using Uri, + /// The object must + /// be either a String representing the filename/path (and the fileField will + /// be the filename without extension), a Record of type + /// (String fileField, String filename/path) or a Record with a third String + /// for the mimeType (if omitted, mimeType will be derived from the filename + /// extension). + /// If using Uri, the object must be a Uri (and the fileField will be + /// "file1", "file2" etc), or a Record with fileField and Uri, and the + /// mimeType will be attempted to be derived from the Uri, or + /// a Record with fileField, Uri and mimetype + /// Each file must be based in the directory represented by the combination + /// of [baseDirectory] and [directory], unless a full filepath is given + /// instead of only the filename. For example: "hello.txt" or + /// "/data/com.myapp/data/dir/hello.txt" + /// [headers] an optional map of HTTP request headers + /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) + /// [fields] optional map of name/value pairs to upload + /// along with the file as form fields + /// [directory] optional directory name, precedes [filename] + /// [baseDirectory] one of the base directories, precedes [directory] + /// [group] if set allows different callbacks or processing for different + /// groups + /// [updates] the kind of progress updates requested + /// [requiresWiFi] if set, will not start upload until WiFi is available. + /// If not set may start upload over cellular network + /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5. + /// On Android 14+, setting priority to 0 requires the + /// `android.permission.RUN_USER_INITIATED_JOBS` permission in AndroidManifest.xml. + /// If the permission is missing, the downloader will automatically fall back to normal operation (which may include a foreground service if configured). + /// [retries] if >0 will retry a failed upload this many times + /// [metaData] user data + /// [displayName] human readable name for this task + /// [creationTime] time of task creation, 'now' by default. + /// [options] optional task-specific configuration using [TaskOptions] + MultiUploadTask({ + super.taskId, + required super.url, + super.urlQueryParameters, + required List files, + super.headers, + super.httpRequestMethod, + super.fields = const {}, + super.directory, + super.baseDirectory, + super.group, + super.updates, + super.requiresWiFi, + super.priority, + super.retries, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : fileFields = files + .map( + (e) => switch (e) { + final String filename => p.basenameWithoutExtension(filename), + (final String fileField, String _) || + (final String fileField, String _, String _) || + (final String fileField, Uri _) || + (final String fileField, Uri _, String _) => fileField, + Uri _ => 'file${files.indexOf(e) + 1}', + _ => throw ArgumentError(_filesArgumentError), + }, + ) + .toList(growable: false), + filenames = files + .map( + (e) => switch (e) { + final String filename || + (String _, final String filename) || + (String _, final String filename, String _) => filename, + final Uri uri || + (String _, final Uri uri) || + (String _, final Uri uri, String _) => uri.toString(), + _ => throw ArgumentError(_filesArgumentError), + }, + ) + .toList(growable: false), + mimeTypes = files + .map( + (e) => switch (e) { + final String filename || (String _, final String filename) => + lookupMimeType(filename) ?? 'application/octet-stream', + (String _, String _, final String mimeType) || + (String _, Uri _, final String mimeType) => mimeType, + Uri _ || (String _, Uri _) => '', + _ => throw ArgumentError(_filesArgumentError), + }, + ) + .toList(growable: false), + super(filename: 'multi-upload', fileField: '', mimeType: ''); + + /// For [MultiUploadTask], returns jsonEncoded list of [fileFields] + @override + String get fileField => jsonEncode(fileFields); + + /// For [MultiUploadTask], returns jsonEncoded list of [filenames] + @override + String get filename => jsonEncode(filenames); + + /// For [MultiUploadTask], returns jsonEncoded list of [mimeTypes] + @override + String get mimeType => jsonEncode(mimeTypes); + + /// Creates [MultiUploadTask] object from [json] + MultiUploadTask.fromJson(super.json) + : assert( + json['taskType'] == 'MultiUploadTask', + 'The provided JSON map is not' + ' a MultiUploadTask, because key "taskType" is not "MultiUploadTask".', + ), + fileFields = List.from(jsonDecode(json['fileField'] as String? ?? '[]')), + filenames = List.from(jsonDecode(json['filename'] as String? ?? '[]')), + mimeTypes = List.from(jsonDecode(json['mimeType'] as String? ?? '[]')), + super.fromJson(); + + @override + MultiUploadTask copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? fileField, + String? mimeType, + Map? fields, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? priority, + int? retries, + int? retriesRemaining, + bool? allowPause, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => MultiUploadTask( + taskId: taskId ?? this.taskId, + url: url ?? this.url, + files: fileFields.indexed.map(_toRecord).toList(), + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + fields: fields ?? this.fields, + directory: directory ?? this.directory, + baseDirectory: baseDirectory ?? this.baseDirectory, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + priority: priority ?? this.priority, + retries: retries ?? this.retries, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; + + /// Zips the fileField, filename and mimeType at an index to + /// a record + (String, String, String) _toRecord((int, String) record) => + (fileFields[record.$1], filenames[record.$1], mimeTypes[record.$1]); + + @override + String get taskType => 'MultiUploadTask'; +} + +final class ParallelDownloadTask extends DownloadTask { + /// List of URLs to download the file from + final List urls; + + /// Number of chunks per URL + final int chunks; + + /// Creates a [ParallelDownloadTask] + /// + /// A [ParallelDownloadTask] is a [DownloadTask] that downloads the file + /// from one or more URLs, and in one or more chunks per URL. The parallel + /// download may speed up download from slow or restrictive servers. + /// + /// [taskId] must be unique. A unique id will be generated if omitted + /// [url] properly encoded if necessary, can include query parameters + /// and can be a list of urls, each providing the same file. The same + /// [urlQueryParameters] and [headers] will be applied to all urls in the list + /// [urlQueryParameters] may be added and will be appended to the [url], must + /// be properly encoded if necessary + /// [filename] of the file to save. If omitted, a random filename will be + /// generated + /// [headers] an optional map of HTTP request headers + /// [httpRequestMethod] the HTTP request method used (e.g. GET) + /// [chunks] the number of chunks to break the download into, i.e. the + /// number of downloads that will happen in parallel + /// [directory] optional directory name, precedes [filename] + /// [baseDirectory] one of the base directories, precedes [directory] + /// [group] if set allows different callbacks or processing for different + /// groups + /// [updates] the kind of progress updates requested + /// [requiresWiFi] if set, will not start download until WiFi is available. + /// If not set may start download over cellular network + /// [retries] if >0 will retry a failed download this many times + /// [allowPause] if true, allows pause command + /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5. + /// On Android 14+, setting priority to 0 requires the + /// `android.permission.RUN_USER_INITIATED_JOBS` permission in AndroidManifest.xml. + /// If the permission is missing, the downloader will automatically fall back to normal operation (which may include a foreground service if configured). + /// [metaData] user data + /// [displayName] human readable name for this task + /// [creationTime] time of task creation, 'now' by default. + /// [options] optional task-specific configuration using [TaskOptions] + /// [transferHints] optional hints to auto-tune task priority, updates, and pause behavior + /// [notificationConfig] optional system notification configuration + /// [stallTimeout] optional duration after which an inactive running transfer is kicked/resumed + /// + /// A [ParallelDownloadTask] cannot be paused or resumed on failure + ParallelDownloadTask({ + super.taskId, + required dynamic url, + super.urlQueryParameters, + super.filename, + super.headers, + super.httpRequestMethod, + this.chunks = 1, + super.directory, + super.baseDirectory, + super.group, + super.updates, + super.requiresWiFi, + super.retries, + super.allowPause, + super.priority, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : assert( + url is String || url is List, + 'The `url` parameter must be a string or a list of strings', + ), + assert( + url is String || (url is List && url.isNotEmpty), + 'The list of urls must not be empty', + ), + urls = url is String + ? [urlWithQueryParameters(url, urlQueryParameters)] + : List.from( + url.map((e) => urlWithQueryParameters(e, urlQueryParameters)), + ), + super(url: url is String ? url : url.first) { + retriesRemaining = 0; // chunk tasks will retry instead, based on [retries] + } + + /// Creates [ParallelDownloadTask] object from [json] + ParallelDownloadTask.fromJson(super.json) + : assert( + json['taskType'] == 'ParallelDownloadTask', + 'The provided JSON map is not a ParallelDownloadTask, ' + 'because key "taskType" is not "ParallelDownloadTask".', + ), + urls = List.from(json['urls'] as List? ?? []), + chunks = json['chunks'] as int? ?? 1, + super.fromJson(); + + @override + Map toJson() => { + ...super.toJson(), + 'urls': urls, + 'chunks': chunks, + }; + + @override + String get taskType => 'ParallelDownloadTask'; + + @override + ParallelDownloadTask copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? retries, + int? retriesRemaining, + bool? allowPause, + int? priority, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => ParallelDownloadTask( + taskId: taskId ?? this.taskId, + url: url ?? urls, + filename: filename ?? this.filename, + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + chunks: chunks, + directory: directory ?? this.directory, + baseDirectory: baseDirectory ?? this.baseDirectory, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + retries: retries ?? this.retries, + allowPause: allowPause ?? this.allowPause, + priority: priority ?? this.priority, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; +} + +/// Class for background requests that do not involve a file +/// +/// Closely resembles a Task, with fewer fields available during construction +final class DataTask extends Task { + /// Creates a [DataTask] that runs in the background, but does not involve a + /// file + /// + /// [taskId] must be unique. A unique id will be generated if omitted + /// [url] properly encoded if necessary, can include query parameters + /// [urlQueryParameters] may be added and will be appended to the [url], must + /// be properly encoded if necessary + /// [headers] an optional map of HTTP request headers + /// [httpRequestMethod] the HTTP request method used (e.g. GET, POST) + /// [post] String post body, encoded in utf8 + /// [json] if given will encode [json] to string and use as the [post] data + /// [contentType] sets the Content-Type header to this value. If omitted and + /// [post] is given, it will be set to 'text-plain; charset=utf-8' and if + /// [json] is given, it will be set to 'application/json] + /// [group] if set allows different callbacks or processing for different + /// groups + /// [updates] the kind of progress updates requested (only .status or none) + /// [requiresWiFi] if set, restricts task to non-metered / non-cellular connections + /// (on Android API 28+, requires a Wi-Fi connection; on older Android versions, requires an unmetered connection; on iOS, disables cellular access). + /// If not set, task may execute over cellular or metered networks. + /// [retries] if >0 will retry a failed download this many times + /// [priority] in range 0 <= priority <= 10 with 0 highest, defaults to 5. + /// On Android 14+, setting priority to 0 requires the + /// `android.permission.RUN_USER_INITIATED_JOBS` permission in AndroidManifest.xml. + /// If the permission is missing, the downloader will automatically fall back to normal operation (which may include a foreground service if configured). + /// [metaData] user data + /// [displayName] human readable name for this task + /// [creationTime] time of task creation, 'now' by default. + /// [options] optional task-specific configuration using [TaskOptions] + /// [transferHints] optional hints to auto-tune task priority, updates, and pause behavior + /// [notificationConfig] optional system notification configuration + /// [stallTimeout] optional duration after which an inactive running transfer is kicked/resumed + DataTask({ + super.taskId, + required super.url, + super.urlQueryParameters, + super.headers, + super.httpRequestMethod, + String? post, + Map? json, + String? contentType, + super.group, + super.updates = Updates.status, + super.requiresWiFi, + super.retries, + super.metaData, + super.displayName, + super.priority, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : assert( + const [Updates.status, Updates.none].contains(updates), + 'DataTasks can only provide status updates', + ), + super( + post: json != null ? jsonEncode(json) : post, + baseDirectory: BaseDirectory.temporary, + allowPause: false, + ) { + // if no content-type header set, it is set to [contentType] or + // (if post or json is given) to text/plain or application/json + if (!headers.containsKey('Content-Type') && + !headers.containsKey('content-type')) { + try { + if (contentType != null) { + headers['Content-Type'] = contentType; + } else if ((post != null || json != null)) { + assert( + (post != null) ^ (json != null), + 'Only post or json can be set, not both', + ); + headers['Content-Type'] = json != null + ? 'application/json' + : 'text/plain; charset=utf-8'; + } + } on UnsupportedError { + _log.warning( + 'Could not add Content-Type header as supplied header is const', + ); + } + } + } + + @override + Task copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? retries, + int? retriesRemaining, + bool? allowPause, + int? priority, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => DataTask( + taskId: taskId ?? this.taskId, + url: url ?? this.url, + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + post: post as String? ?? this.post, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + retries: retries ?? this.retries, + priority: priority ?? this.priority, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; + + /// Creates [DataTask] object from [json] + DataTask.fromJson(super.json) + : assert( + json['taskType'] == 'DataTask', + 'The provided JSON map is not a DataTask, ' + 'because key "taskType" is not "DataTask".', + ), + super.fromJson(); + + @override + String get taskType => 'DataTask'; +} diff --git a/third_party/background_downloader/lib/src/transfer.dart b/third_party/background_downloader/lib/src/transfer.dart new file mode 100644 index 0000000..88d9447 --- /dev/null +++ b/third_party/background_downloader/lib/src/transfer.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'exceptions.dart'; +import 'file_downloader.dart'; +import 'models.dart'; +import 'task.dart'; + +/// The specific reason a transfer is currently held or waiting. +enum TransferHoldReason { + /// The transfer is not held (actively transferring, enqueued, completed, or failed). + none, + + /// The transfer is held because the device has no internet connection. + offline, + + /// The transfer is held because it requires Wi-Fi and the device is currently on a cellular connection. + waitingForWiFi, +} + +/// Represents a single managed background file transfer (download, upload, or data task). +/// +/// Returned by [Transfers.start], [Transfers.getOrStart], +/// and [Transfers.startAll]. +/// +/// Provides reactive [ValueNotifier] handles for Flutter UI data-binding, +/// broadcast streams for events, and awaitable [result] and [file] Futures. +class Transfer { + /// The underlying [Task] object associated with this transfer. + Task _task; + Task get task => _task; + + /// The [FileDownloader] instance orchestrating this transfer. + final FileDownloader downloader; + + /// Progress normalized strictly between `0.0` and `1.0` (or `null` if indeterminate). + /// + /// Unlike lower-level progress streams, this notifier never receives negative status + /// sentinels; status changes are communicated separately via [statusNotifier]. + final ValueNotifier progressNotifier = ValueNotifier(null); + + /// Current task status ([TaskStatus.enqueued], [TaskStatus.running], [TaskStatus.complete], etc.). + final ValueNotifier statusNotifier = ValueNotifier( + TaskStatus.enqueued, + ); + + /// Current transfer speed in MB/s (or `-1.0` if unknown or not calculating). + final ValueNotifier networkSpeedNotifier = ValueNotifier( + -1.0, + ); + + /// Estimated time remaining for this transfer (or [Duration.zero] if unknown). + final ValueNotifier timeRemainingNotifier = ValueNotifier( + Duration.zero, + ); + + /// The [TaskException] associated with a failed transfer, or `null` if not failed. + final ValueNotifier exceptionNotifier = + ValueNotifier(null); + + /// Reactive [ValueNotifier] indicating why this transfer is held (e.g. offline or waiting for Wi-Fi). + final ValueNotifier holdReasonNotifier = + ValueNotifier(TransferHoldReason.none); + + /// Reactive [ValueNotifier] emitting the [NotificationType] whenever the user taps + /// a system notification associated with this transfer (or `null` if not tapped). + final ValueNotifier notificationTapNotifier = + ValueNotifier(null); + + final StreamController _updatesController = + StreamController.broadcast(); + + Completer _resultCompleter = Completer(); + + Timer? _stallWatchdogTimer; + DateTime _lastProgressTime = DateTime.now(); + + /// Creates a new [Transfer] handle. + Transfer( + Task task, [ + FileDownloader? downloader, + TaskStatus initialStatus = TaskStatus.enqueued, + double? initialProgress, + TaskException? initialException, + TransferHoldReason initialHoldReason = TransferHoldReason.none, + ]) : _task = task, + downloader = downloader ?? FileDownloader() { + statusNotifier.value = initialStatus; + holdReasonNotifier.value = initialHoldReason; + if (initialProgress != null && + initialProgress >= 0.0 && + initialProgress <= 1.0) { + progressNotifier.value = initialProgress; + } + exceptionNotifier.value = initialException; + + if (initialStatus.isFinalState) { + _resultCompleter.complete( + TaskStatusUpdate(task, initialStatus, initialException), + ); + } + + _setupStallWatchdog(); + } + + /// Unique task identifier. + String get taskId => task.taskId; + + /// Current [TaskStatus] of this transfer. + TaskStatus get status => statusNotifier.value; + + /// Current normalized progress (`0.0` to `1.0`, or `null`). + double? get progress => progressNotifier.value; + + /// Current network speed in MB/s. + double get networkSpeed => networkSpeedNotifier.value; + + /// Latest [TaskException], if the transfer failed. + TaskException? get exception => exceptionNotifier.value; + + /// The current reason this transfer is held. + TransferHoldReason get holdReason => holdReasonNotifier.value; + + /// True if the transfer is paused/held waiting for a Wi-Fi connection. + bool get isWaitingForWiFi => holdReason == TransferHoldReason.waitingForWiFi; + + /// True if the transfer is paused/held because the device is offline. + bool get isOffline => holdReason == TransferHoldReason.offline; + + /// The most recent [NotificationType] tapped on a system notification for this transfer, or `null`. + NotificationType? get notificationTap => notificationTapNotifier.value; + + /// Combined broadcast stream of all [TaskStatusUpdate] and [TaskProgressUpdate] events for this transfer. + Stream get updates => _updatesController.stream; + + /// Stream emitting only [TaskStatusUpdate] events for this transfer. + Stream get statusUpdates => + updates.where((u) => u is TaskStatusUpdate).cast(); + + /// Stream emitting only [TaskProgressUpdate] events for this transfer. + Stream get progressUpdates => + updates.where((u) => u is TaskProgressUpdate).cast(); + + /// Completes when the task reaches a final state ([TaskStatus.complete], + /// [TaskStatus.failed], [TaskStatus.canceled], [TaskStatus.notFound]). + Future get result => _resultCompleter.future; + + /// Resolves to the downloaded [File] upon successful completion. + /// + /// Throws [TaskException] if the transfer fails, is canceled, or is not found. + Future get file async { + if (task is DataTask) { + throw TaskException('DataTask does not produce a file on disk'); + } + final statusUpdate = await result; + if (statusUpdate.status == TaskStatus.complete) { + final path = await statusUpdate.task.filePath(); + return File(path); + } + throw statusUpdate.exception ?? + TaskException('Task ended with status ${statusUpdate.status}'); + } + + /// Resolves to the server response body String upon completion, or null. + Future get responseBody async { + final statusUpdate = await result; + return statusUpdate.responseBody; + } + + /// Explicitly allows this transfer to proceed over cellular data even if + /// `requiresWiFi` was set on the task. + Future allowCellular() async { + holdReasonNotifier.value = TransferHoldReason.none; + final updatedTask = task.copyWith(requiresWiFi: false); + _task = updatedTask; + if (updatedTask case final DownloadTask dTask when dTask.allowPause) { + if (await downloader.taskCanResume(dTask)) { + return downloader.resume(dTask); + } + } + return downloader.enqueue(updatedTask); + } + + /// Pauses this transfer if supported. + Future pause() => switch (task) { + final DownloadTask dTask => downloader.pause(dTask), + _ => Future.value(false), + }; + + /// Resumes this transfer. + /// + /// If this transfer can be resumed using saved resume data, resumes it. + /// Otherwise, re-enqueues the task with its retry count reset. + Future resume() async { + holdReasonNotifier.value = TransferHoldReason.none; + if (_resultCompleter.isCompleted) { + _resultCompleter = Completer(); + } + if (task case final DownloadTask dTask when dTask.allowPause) { + if (await downloader.taskCanResume(dTask)) { + return downloader.resume(dTask); + } + } + final resetTask = task.copyWith(retriesRemaining: task.retries); + _task = resetTask; + return downloader.enqueue(resetTask); + } + + /// Cancels this transfer. + Future cancel() { + holdReasonNotifier.value = TransferHoldReason.none; + return downloader.cancelTaskWithId(task.taskId); + } + + /// Internal status update handler invoked by [FileDownloader]. + void updateStatus(TaskStatusUpdate update) { + _task = update.task; + statusNotifier.value = update.status; + if (update.exception != null) { + exceptionNotifier.value = update.exception; + } + + if (update.status == TaskStatus.enqueued || + update.status == TaskStatus.running) { + if (_resultCompleter.isCompleted) { + _resultCompleter = Completer(); + } + exceptionNotifier.value = null; + } + + if (update.status == TaskStatus.running) { + _lastProgressTime = DateTime.now(); + if (progressNotifier.value == null) { + progressNotifier.value = 0.0; + } + holdReasonNotifier.value = TransferHoldReason.none; + } else if (update.status == TaskStatus.complete) { + progressNotifier.value = 1.0; + holdReasonNotifier.value = TransferHoldReason.none; + } else if (update.status.isFinalState) { + holdReasonNotifier.value = TransferHoldReason.none; + } else if (update.status == TaskStatus.waitingToRetry) { + if (!downloader.isConnected) { + holdReasonNotifier.value = TransferHoldReason.offline; + } else if (task.requiresWiFi && !downloader.isWiFi) { + holdReasonNotifier.value = TransferHoldReason.waitingForWiFi; + } + } + + if (!_updatesController.isClosed) { + _updatesController.add(update); + } + + if (update.status.isFinalState && !_resultCompleter.isCompleted) { + _resultCompleter.complete(update); + } + } + + /// Internal progress update handler invoked by [FileDownloader]. + void updateProgress(TaskProgressUpdate update) { + _task = update.task; + _lastProgressTime = DateTime.now(); + if (update.progress >= 0.0 && update.progress <= 1.0) { + progressNotifier.value = update.progress; + } + if (update.hasNetworkSpeed) { + networkSpeedNotifier.value = update.networkSpeed; + } + if (update.hasTimeRemaining) { + timeRemainingNotifier.value = update.timeRemaining; + } + if (!_updatesController.isClosed) { + _updatesController.add(update); + } + } + + /// Internal notification tap handler invoked by [FileDownloader]. + void onNotificationTap(NotificationType type) { + notificationTapNotifier.value = type; + } + + void _setupStallWatchdog() { + final timeout = task.stallTimeout; + if (timeout == null) return; + + _stallWatchdogTimer?.cancel(); + _stallWatchdogTimer = Timer.periodic(const Duration(seconds: 5), (_) async { + if (statusNotifier.value == TaskStatus.running) { + final elapsed = DateTime.now().difference(_lastProgressTime); + if (elapsed >= timeout) { + _lastProgressTime = DateTime.now(); + if (task case final DownloadTask dTask when dTask.allowPause) { + await pause(); + } + await resume(); + } + } + }); + } + + /// Disposes all internal streams, timers, and notifiers. + void dispose() { + _stallWatchdogTimer?.cancel(); + _updatesController.close(); + progressNotifier.dispose(); + statusNotifier.dispose(); + networkSpeedNotifier.dispose(); + timeRemainingNotifier.dispose(); + exceptionNotifier.dispose(); + holdReasonNotifier.dispose(); + notificationTapNotifier.dispose(); + } +} diff --git a/third_party/background_downloader/lib/src/transfer_hint.dart b/third_party/background_downloader/lib/src/transfer_hint.dart new file mode 100644 index 0000000..05b80a1 --- /dev/null +++ b/third_party/background_downloader/lib/src/transfer_hint.dart @@ -0,0 +1,41 @@ +import 'task.dart'; + +/// Composable hints provided to [Task] to automatically tune +/// task parameters (such as priority, update modes, suggested filename, and pause resilience) +/// based on caller intent. +enum TransferHint { + /// The user is actively waiting for this transfer. + /// + /// Sets `priority = 0` (activates Android 14+ User-Initiated Data Transfer / UIDT & iOS max priority 1.0) and `allowPause = true`. + /// + /// **Important for Android 14+ (UIDT):** Android requires user-initiated data transfers + /// to display a user-visible notification while running. Therefore, tasks with [userInitiated] + /// should preferably include a notification configuration—either directly on [Task.notificationConfig] + /// or globally via [FileDownloader.configureNotification] or [FileDownloader.configureNotificationForGroup]. + userInitiated, + + /// The transfer is a large file (> 50-100 MB or takes minutes). + /// + /// Ensures [Task.allowPause] is `true` (enabling Android 9-minute auto-resume cycles). + largeFile, + + /// The transfer is small (or one of many bulk items). + /// + /// Sets `updates = Updates.status` to eliminate MethodChannel progress chatter. + smallFile, + + /// Low-priority background sync or maintenance transfer. + /// + /// Sets `priority = 10` (lowest priority) so it does not compete with user-initiated transfers. + lowPriority, + + /// Uses server `Content-Disposition` headers to determine the filename. + /// + /// Sets [DownloadTask.filename] to [DownloadTask.suggestedFilename] (`'?'`). + useSuggestedFilename, + + /// For an [UploadTask], uploads raw file bytes directly in the HTTP request body + /// (sets `post: 'binary'`), suitable for Amazon S3 presigned URLs, Google Cloud Storage, + /// and direct binary REST APIs, instead of multipart/form-data. + binaryUpload, +} diff --git a/third_party/background_downloader/lib/src/transfers.dart b/third_party/background_downloader/lib/src/transfers.dart new file mode 100644 index 0000000..a415208 --- /dev/null +++ b/third_party/background_downloader/lib/src/transfers.dart @@ -0,0 +1,624 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'base_downloader.dart'; +import 'database.dart'; +import 'exceptions.dart'; +import 'file_downloader.dart'; +import 'models.dart'; +import 'task.dart'; +import 'transfer.dart'; +import 'transfer_hint.dart'; + +/// Manages active and completed [Transfer] objects, providing high-level methods +/// to start, batch start, reconnect to, and query background transfers. +/// +/// Accessed via [FileDownloader.transfers]. +class Transfers { + /// The parent [FileDownloader] coordinating this transfer manager. + final FileDownloader downloader; + final BaseDownloader _downloader; + + final Map _transfers = {}; + final Set _registeredTransferGroups = {}; + + /// ValueNotifier containing the list of all currently tracked [Transfer] objects. + final ValueNotifier> notifier = ValueNotifier>( + [], + ); + + static bool _transferAutoCleanTriggered = false; + StreamSubscription? _databaseSubscription; + + Transfers(this.downloader, this._downloader) { + _ensureDatabaseSubscription(); + } + + void _ensureDatabaseSubscription() { + _databaseSubscription ??= downloader.database.updates.listen( + _onDatabaseRecordUpdate, + ); + } + + void _ensureTransferAutoClean() { + if (!_transferAutoCleanTriggered && + _downloader.isTrackingTasks && + !downloader.database.autoClean) { + _transferAutoCleanTriggered = true; + downloader.database.cleanUp(autoClean: true); + } + } + + Task _ensureProvidesStatusUpdates(Task task) => switch (task.updates) { + Updates.progress => task.copyWith(updates: Updates.statusAndProgress), + Updates.none => task.copyWith(updates: Updates.status), + Updates.status || Updates.statusAndProgress => task, + }; + + /// Enqueues a task and returns a [Transfer] object to manage and observe its progress. + /// + /// If the task includes a [Task.notificationConfig] or [TransferHint.userInitiated], + /// notification behavior is configured automatically. + Future start(Task task) async { + _ensureDatabaseSubscription(); + _ensureTransferAutoClean(); + task = _ensureProvidesStatusUpdates(task); + final namespacedTask = downloader.withNamespacedGroup(task); + _ensureTransferGroupRegistered(namespacedTask.group); + + if (task.notificationConfig != null) { + downloader.configureNotificationForTask( + namespacedTask, + running: task.notificationConfig!.running, + complete: task.notificationConfig!.complete, + error: task.notificationConfig!.error, + paused: task.notificationConfig!.paused, + canceled: task.notificationConfig!.canceled, + progressBar: task.notificationConfig!.progressBar, + tapOpensFile: task.notificationConfig!.tapOpensFile, + groupNotificationId: task.notificationConfig!.groupNotificationId, + ); + } else if (task.transferHints?.contains(TransferHint.userInitiated) == + true) { + final existingConfig = downloader.notificationConfigForTask( + namespacedTask, + ); + if (existingConfig == null || existingConfig.running == null) { + final isUpload = task is UploadTask; + final action = isUpload ? 'Uploading' : 'Downloading'; + downloader.configureNotificationForTask( + namespacedTask, + running: TaskNotification(action, '{progress}'), + complete: TaskNotification('$action complete', ''), + error: TaskNotification('$action failed', ''), + progressBar: true, + ); + } + } + + final cleanTask = downloader.withoutNamespacedGroup(namespacedTask); + final transfer = _getOrCreateTransfer(cleanTask); + + final isOnline = _downloader.isConnected; + final isWiFi = _downloader.isWiFi; + final needsWiFi = task.requiresWiFi; + + if (!isOnline) { + transfer.holdReasonNotifier.value = TransferHoldReason.offline; + } else if (needsWiFi && !isWiFi) { + transfer.holdReasonNotifier.value = TransferHoldReason.waitingForWiFi; + } else { + transfer.holdReasonNotifier.value = TransferHoldReason.none; + } + + transfer.updateStatus(TaskStatusUpdate(cleanTask, TaskStatus.enqueued)); + _notifyTransfersChanged(); + + await downloader.enqueue(namespacedTask); + return transfer; + } + + /// Batch enqueues multiple [Task]s and returns their corresponding [Transfer] objects. + /// + /// Optionally accepts an [onProgress] callback that is invoked whenever any of the + /// transfers in this batch complete or fail, reporting `(succeededCount, failedCount)`. + Future> startAll( + List tasks, { + void Function(int succeeded, int failed)? onProgress, + }) async { + if (tasks.isEmpty) return []; + + _ensureDatabaseSubscription(); + _ensureTransferAutoClean(); + + final namespacedTasks = []; + final resultTransfers = []; + final tasksToEnqueue = []; + + for (var task in tasks) { + task = _ensureProvidesStatusUpdates(task); + final namespacedTask = downloader.withNamespacedGroup(task); + namespacedTasks.add(namespacedTask); + _ensureTransferGroupRegistered(namespacedTask.group); + + if (task.notificationConfig != null) { + downloader.configureNotificationForTask( + namespacedTask, + running: task.notificationConfig!.running, + complete: task.notificationConfig!.complete, + error: task.notificationConfig!.error, + paused: task.notificationConfig!.paused, + canceled: task.notificationConfig!.canceled, + progressBar: task.notificationConfig!.progressBar, + tapOpensFile: task.notificationConfig!.tapOpensFile, + groupNotificationId: task.notificationConfig!.groupNotificationId, + ); + } + + final cleanTask = downloader.withoutNamespacedGroup(namespacedTask); + final transfer = _getOrCreateTransfer(cleanTask); + resultTransfers.add(transfer); + + final isOnline = _downloader.isConnected; + final isWiFi = _downloader.isWiFi; + final needsWiFi = task.requiresWiFi; + + if (!isOnline) { + transfer.holdReasonNotifier.value = TransferHoldReason.offline; + } else if (needsWiFi && !isWiFi) { + transfer.holdReasonNotifier.value = TransferHoldReason.waitingForWiFi; + } else { + transfer.holdReasonNotifier.value = TransferHoldReason.none; + } + transfer.updateStatus(TaskStatusUpdate(cleanTask, TaskStatus.enqueued)); + tasksToEnqueue.add(namespacedTask); + } + + if (tasksToEnqueue.isNotEmpty) { + final results = await downloader.enqueueAll(tasksToEnqueue); + for (var i = 0; i < tasksToEnqueue.length; i++) { + final success = results.length > i ? results[i] : false; + if (!success) { + final cleanTask = downloader.withoutNamespacedGroup( + tasksToEnqueue[i], + ); + final transfer = _transfers[cleanTask.taskId]; + transfer?.updateStatus( + TaskStatusUpdate( + cleanTask, + TaskStatus.failed, + TaskException('Failed to batch enqueue task on native platform'), + ), + ); + } + } + } + + if (onProgress != null && resultTransfers.isNotEmpty) { + var succeeded = 0; + var failed = 0; + for (final transfer in resultTransfers) { + transfer.result.then((update) { + if (update.status == TaskStatus.complete) { + succeeded++; + } else { + failed++; + } + onProgress(succeeded, failed); + }); + } + } + + _notifyTransfersChanged(); + return resultTransfers; + } + + /// Returns an existing [Transfer] matching [task], or starts a new transfer. + Future getOrStart( + Task task, { + bool Function(Task existingTask)? matchBy, + bool reEnqueueIfFailed = true, + }) async { + _ensureTransferAutoClean(); + task = _ensureProvidesStatusUpdates(task); + var existing = forTask(task, matchBy: matchBy); + if (existing == null && _downloader.isTrackingTasks) { + existing = await _findExistingInDatabase(task, matchBy: matchBy); + } + if (existing != null) { + if (existing.status == TaskStatus.complete || + existing.status.isNotFinalState) { + return existing; + } + if (!reEnqueueIfFailed) { + return existing; + } + } + return start(task); + } + + /// Returns existing [Transfer] handles matching [tasks] where available, + /// or starts transfers for those not already active or completed. + /// + /// Tasks needing to be started are dispatched using batch enqueue. + Future> startOrGetAll( + List tasks, { + bool Function(Task existingTask)? matchBy, + bool reEnqueueIfFailed = true, + BatchProgressCallback? onProgress, + }) async { + _ensureTransferAutoClean(); + final results = []; + final tasksToStart = []; + + for (var task in tasks) { + task = _ensureProvidesStatusUpdates(task); + var existing = forTask(task, matchBy: matchBy); + if (existing == null && _downloader.isTrackingTasks) { + existing = await _findExistingInDatabase(task, matchBy: matchBy); + } + if (existing != null && + (existing.status == TaskStatus.complete || + existing.status.isNotFinalState || + !reEnqueueIfFailed)) { + results.add(existing); + } else { + final namespacedTask = downloader.withNamespacedGroup(task); + final cleanTask = downloader.withoutNamespacedGroup(namespacedTask); + final transfer = _getOrCreateTransfer(cleanTask); + results.add(transfer); + tasksToStart.add(task); + } + } + + if (tasksToStart.isNotEmpty) { + await startAll(tasksToStart); + } + + if (onProgress != null && results.isNotEmpty) { + var succeeded = 0; + var failed = 0; + for (final transfer in results) { + if (transfer.status.isFinalState) { + if (transfer.status == TaskStatus.complete) { + succeeded++; + } else { + failed++; + } + } else { + transfer.result.then((update) { + if (update.status == TaskStatus.complete) { + succeeded++; + } else { + failed++; + } + onProgress(succeeded, failed); + }); + } + } + onProgress(succeeded, failed); + } + + return results; + } + + /// Alias for [startOrGetAll]. + Future> getOrStartAll( + List tasks, { + bool Function(Task existingTask)? matchBy, + bool reEnqueueIfFailed = true, + BatchProgressCallback? onProgress, + }) => startOrGetAll( + tasks, + matchBy: matchBy, + reEnqueueIfFailed: reEnqueueIfFailed, + onProgress: onProgress, + ); + + /// Finds an existing active or tracked [Transfer] matching [task]. + Transfer? forTask(Task task, {bool Function(Task existingTask)? matchBy}) { + final cleanGroup = downloader.originalGroup( + downloader.namespacedGroup(task.group), + ); + for (final transfer in _transfers.values) { + if (transfer.task.group != cleanGroup) continue; + if (matchBy != null) { + if (matchBy(transfer.task)) return transfer; + continue; + } + if (transfer.task.taskId == task.taskId) return transfer; + if (transfer.task.url == task.url && + transfer.task.filename == task.filename && + transfer.task.directory == task.directory && + transfer.task.baseDirectory == task.baseDirectory) { + return transfer; + } + } + return null; + } + + /// Returns the [Transfer] for [taskId], or null if not found. + Transfer? forId(String taskId) => _transfers[taskId]; + + /// Operator lookup for a [Transfer] by taskId. + Transfer? operator [](String taskId) => _transfers[taskId]; + + /// Removes a [Transfer] by its [taskId] from tracking. + /// + /// If [dispose] is true (the default), disposes the [Transfer] instance. + /// Returns the removed [Transfer], or `null` if not found. + Transfer? remove(String taskId, {bool dispose = true}) { + final transfer = _transfers.remove(taskId); + if (transfer != null) { + if (dispose) { + transfer.dispose(); + } + _notifyTransfersChanged(); + } + return transfer; + } + + /// Returns the first [Transfer] matching [url], or null. + Transfer? forUrl(String url) { + for (final transfer in _transfers.values) { + if (transfer.task.url == url) return transfer; + } + return null; + } + + /// Returns all tracked [Transfer] objects, optionally filtered by [group]. + List all({String? group}) { + if (group == null) { + return _transfers.values.toList(); + } + final cleanGroup = downloader.originalGroup( + downloader.namespacedGroup(group), + ); + return _transfers.values.where((t) => t.task.group == cleanGroup).toList(); + } + + /// Returns all active (non-final state) [Transfer] objects. + List active({String? group}) => + all(group: group).where((t) => t.status.isNotFinalState).toList(); + + /// Returns all completed [Transfer] objects. + List completed({String? group}) => + all(group: group).where((t) => t.status == TaskStatus.complete).toList(); + + void _ensureTransferGroupRegistered(String namespacedGroup) { + if (_registeredTransferGroups.contains(namespacedGroup)) return; + _registeredTransferGroups.add(namespacedGroup); + + _downloader.trackTasks(namespacedGroup, true); + + _downloader.groupTransferStatusCallbacks[namespacedGroup] = (rawUpdate) { + final cleanUpdate = TaskStatusUpdate( + downloader.withoutNamespacedGroup(rawUpdate.task), + rawUpdate.status, + rawUpdate.exception, + rawUpdate.responseBody, + rawUpdate.responseHeaders, + rawUpdate.responseStatusCode, + rawUpdate.mimeType, + rawUpdate.charSet, + ); + _onTransferStatusUpdate(cleanUpdate); + }; + + _downloader.groupTransferProgressCallbacks[namespacedGroup] = (rawUpdate) { + final cleanUpdate = TaskProgressUpdate( + downloader.withoutNamespacedGroup(rawUpdate.task), + rawUpdate.progress, + rawUpdate.expectedFileSize, + rawUpdate.networkSpeed, + rawUpdate.timeRemaining, + ); + _onTransferProgressUpdate(cleanUpdate); + }; + + _downloader.groupTransferNotificationTapCallbacks[namespacedGroup] = + (rawTask, notificationType) { + _onTransferNotificationTap( + downloader.withoutNamespacedGroup(rawTask), + notificationType, + ); + }; + } + + void _onTransferStatusUpdate(TaskStatusUpdate update) { + final transfer = _transfers[update.task.taskId]; + if (transfer != null) { + transfer.updateStatus(update); + } + _notifyTransfersChanged(); + } + + void _onTransferProgressUpdate(TaskProgressUpdate update) { + final transfer = _transfers[update.task.taskId]; + if (transfer != null) { + transfer.updateProgress(update); + } + _notifyTransfersChanged(); + } + + void _onTransferNotificationTap( + Task task, + NotificationType notificationType, + ) { + final transfer = _transfers[task.taskId]; + if (transfer != null) { + transfer.onNotificationTap(notificationType); + } + } + + Transfer _getOrCreateTransfer(Task cleanTask) { + var transfer = _transfers[cleanTask.taskId]; + if (transfer == null) { + transfer = Transfer(cleanTask, downloader); + _transfers[cleanTask.taskId] = transfer; + _notifyTransfersChanged(); + } + return transfer; + } + + Transfer _createTransferFromRecord(TaskRecord record) { + final cleanTask = downloader.withoutNamespacedGroup(record.task); + var transfer = _transfers[cleanTask.taskId]; + if (transfer != null) { + return transfer; + } + final normalizedProgress = + (record.progress >= 0.0 && record.progress <= 1.0) + ? record.progress + : (record.status == TaskStatus.complete ? 1.0 : 0.0); + transfer = Transfer( + cleanTask, + downloader, + record.status, + normalizedProgress, + record.exception, + ); + _transfers[cleanTask.taskId] = transfer; + _notifyTransfersChanged(); + if (record.status.isNotFinalState) { + _ensureTransferGroupRegistered(record.task.group); + } + return transfer; + } + + Future _findExistingInDatabase( + Task task, { + bool Function(Task existingTask)? matchBy, + }) async { + final namespacedTask = downloader.withNamespacedGroup(task); + if (matchBy == null) { + final record = await downloader.database.recordForId( + namespacedTask.taskId, + ); + if (record != null) { + return _createTransferFromRecord(record); + } + } + final cleanGroup = downloader.originalGroup( + downloader.namespacedGroup(task.group), + ); + final allRecords = await downloader.database.allRecords(); + for (final record in allRecords) { + final cleanRecordTask = downloader.withoutNamespacedGroup(record.task); + if (cleanRecordTask.group != cleanGroup) continue; + if (matchBy != null) { + if (matchBy(cleanRecordTask)) { + return _createTransferFromRecord(record); + } + continue; + } + if (cleanRecordTask.taskId == task.taskId || + (cleanRecordTask.url == task.url && + cleanRecordTask.filename == task.filename && + cleanRecordTask.directory == task.directory && + cleanRecordTask.baseDirectory == task.baseDirectory)) { + return _createTransferFromRecord(record); + } + } + return null; + } + + void _onDatabaseRecordUpdate(TaskRecord record) { + if (downloader.hasNamespace && + !record.task.group.startsWith('${downloader.namespace}.')) { + return; + } + final cleanTask = downloader.withoutNamespacedGroup(record.task); + final transfer = _transfers[cleanTask.taskId]; + if (transfer != null) { + transfer.updateStatus( + TaskStatusUpdate(cleanTask, record.status, record.exception), + ); + if (record.progress >= 0.0 && record.progress <= 1.0) { + transfer.updateProgress( + TaskProgressUpdate( + cleanTask, + record.progress, + record.expectedFileSize, + ), + ); + } + } else if (record.status.isNotFinalState) { + _createTransferFromRecord(record); + } + } + + /// Rehydrates [Transfer] handles from the persistent database for previously + /// tracked tasks across app restarts. + Future> rehydrateFromDatabase({String? group}) async { + _ensureDatabaseSubscription(); + if (!_downloader.isTrackingTasks) { + return []; + } + final namespacedGroupName = group != null + ? downloader.namespacedGroup(group) + : null; + final records = await downloader.database.allRecords(); + final rehydrated = []; + for (final record in records) { + if (namespacedGroupName != null && + record.task.group != namespacedGroupName) { + continue; + } + if (downloader.hasNamespace && + !record.task.group.startsWith('${downloader.namespace}.')) { + continue; + } + rehydrated.add(_createTransferFromRecord(record)); + } + return rehydrated; + } + + void _notifyTransfersChanged() { + notifier.value = List.unmodifiable(_transfers.values); + } + + /// Clears internal transfer state, notifiers, and group listener registrations. + /// + /// If [cancelActive] is true, cancels any active transfers before clearing. + Future clear({bool cancelActive = false}) async { + if (cancelActive) { + await cancelAll(); + } + _transferAutoCleanTriggered = false; + for (final transfer in _transfers.values) { + transfer.dispose(); + } + _transfers.clear(); + for (final group in _registeredTransferGroups) { + _downloader.groupTransferStatusCallbacks.remove(group); + _downloader.groupTransferProgressCallbacks.remove(group); + _downloader.groupTransferNotificationTapCallbacks.remove(group); + } + _registeredTransferGroups.clear(); + notifier.value = []; + } + + /// Cancels all currently active transfers, optionally filtered by [group]. + Future cancelAll({String? group}) async { + final activeTransfers = active(group: group); + await Future.wait(activeTransfers.map((t) => t.cancel())); + } + + /// Cancels subscriptions and releases resources held by this [Transfers] manager. + void dispose() { + _databaseSubscription?.cancel(); + _databaseSubscription = null; + for (final group in _registeredTransferGroups) { + _downloader.groupTransferStatusCallbacks.remove(group); + _downloader.groupTransferProgressCallbacks.remove(group); + _downloader.groupTransferNotificationTapCallbacks.remove(group); + } + _registeredTransferGroups.clear(); + for (final transfer in _transfers.values) { + transfer.dispose(); + } + _transfers.clear(); + } +} diff --git a/third_party/background_downloader/lib/src/uri/uri_helpers.dart b/third_party/background_downloader/lib/src/uri/uri_helpers.dart new file mode 100644 index 0000000..f458a05 --- /dev/null +++ b/third_party/background_downloader/lib/src/uri/uri_helpers.dart @@ -0,0 +1,42 @@ +/// Packs [filename] and [uri] into a single String +/// +/// use [unpack] to retrieve the filename and uri from the packed String +String pack(String filename, Uri uri) => + ':::$filename::::::${uri.toString()}:::'; + +/// Unpacks [packedString] into a (filename, uri). If this is not a packed +/// string, returns the original [packedString] as (filename and null) or, +/// if it is a Uri as (null and the uri) +({String? filename, Uri? uri}) unpack(String packedString) { + final regex = RegExp(r':::([\s\S]*?)::::::([\s\S]*?):::'); + final match = regex.firstMatch(packedString); + + if (match != null && match.groupCount == 2) { + final filename = match.group(1)!; + final uriString = match.group(2)!; + final uri = Uri.tryParse(uriString); + return (filename: filename, uri: uri?.hasScheme == true ? uri : null); + } else { + final uri = Uri.tryParse(packedString); + if (uri?.hasScheme == true) { + return (filename: null, uri: uri); + } + return (filename: packedString, uri: null); + } +} + +/// Returns the Uri represented by [maybePacked], or null if the String is not a +/// valid Uri or packed Uri string. +/// +/// [maybePacked] should be a full Uri string, or a packed String containing +/// a Uri (see [pack]) +Uri? uriFromStringValue(String maybePacked) { + final (:filename, :uri) = unpack(maybePacked); + return uri; +} + +/// Returns true if [maybePacked] is a valid Uri or packed Uri string. +/// +/// [maybePacked] should be a full Uri string, or a packed String containing +/// a Uri (see [pack]) +bool containsUri(String maybePacked) => uriFromStringValue(maybePacked) != null; diff --git a/third_party/background_downloader/lib/src/uri/uri_task.dart b/third_party/background_downloader/lib/src/uri/uri_task.dart new file mode 100644 index 0000000..d6ccc32 --- /dev/null +++ b/third_party/background_downloader/lib/src/uri/uri_task.dart @@ -0,0 +1,292 @@ +part of '../task.dart'; + +/// Interface for tasks that operate with [Uri] instead of file and directory +/// paths. +/// +/// This allows the Uri version of the different tasks to be testable using +/// `task is UriTask` +abstract interface class UriTask { + /// Returns the URI of the directory associated with the task, or null + Uri? get directoryUri; + + /// Returns the URI of the file associated with the task, or null + Uri? get fileUri; +} + +/// Mixin that implements [UriTask] with the standard implementation, which +/// implements the getters for [directoryUri] and [fileUri], and overrides +/// the getters for [directory] and [filename] because the actual contents +/// of those properties contain encoded information that should not be +/// interpreted directly +base mixin _UriTaskMixin on Task implements UriTask { + @override + Uri? get directoryUri => + super.directory.isNotEmpty ? Uri.tryParse(super.directory) : null; + + @override + Uri? get fileUri { + final (:filename, :uri) = unpack(super.filename); + return uri; + } + + @override + String get directory => ''; + + @override + String get filename { + final (:filename, :uri) = unpack(super.filename); + return filename ?? ''; + } + + /// Creates JSON map of this object + @override + Map toJson() => { + ...super.toJson(), + 'filename': super.filename, // replace with raw string + 'directory': super.directory, // replace with raw string + }; +} + +final class UriDownloadTask extends DownloadTask with _UriTaskMixin { + /// Creates [UriDownloadTask] using a URI as the destination directory + /// + /// For Android: + /// Content URIs are related to the Android Storage Framework that makes it + /// easier to get access to a file system location without the need for + /// app permissions, provided the user has chosen that location using a + /// file picker. They follow the content:// scheme, and can be obtained + /// by calling [UriUtils.pickDirectory]. + /// + /// For other platforms: + /// File URIs follow the file:// scheme and point to a destination on the + /// file system. A file:// URI is returned from [UriUtils.pickDirectory] on platforms + /// other than Android. + /// + /// The [directoryUri] can be obtained using the + /// [directoryUri] getter. [directory] will always return the empty string + /// + /// Note that the result of [Task.filePath] is undefined when using a URI, as + /// not all Uri types can be converted to a file name + UriDownloadTask({ + super.taskId, + required super.url, + super.urlQueryParameters, + String? filename, + super.headers, + super.httpRequestMethod, + super.post, + required Uri directoryUri, + super.group, + super.updates, + super.requiresWiFi, + super.retries, + super.allowPause, + super.priority, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : super( + baseDirectory: BaseDirectory.root, + directory: directoryUri.toString(), + filename: switch ((filename, directoryUri)) { + (DownloadTask.suggestedFilename, _) => + DownloadTask.suggestedFilename, + (null, Uri(scheme: 'file')) => () { + final randomFilename = Random().nextInt(1 << 32).toString(); + return pack( + randomFilename, + Uri.file( + '${directoryUri.path}/$randomFilename', + windows: Platform.isWindows, + ), + ); + }(), + (String filename, Uri(scheme: 'file')) => pack( + filename, + Uri.file( + '${directoryUri.path}/$filename', + windows: Platform.isWindows, + ), + ), + _ => filename, + }, + ) { + assert( + Task.allowedUriSchemes.contains(directoryUri.scheme), + 'Directory URI scheme must be one of ${Task.allowedUriSchemes}', + ); + if (_pathSeparatorRegExp.hasMatch(filename ?? '')) { + throw ArgumentError('Filename cannot contain path separators'); + } + } + + /// Creates [Task] object from JsonMap + /// + /// Only used by subclasses. Use [Task.createFromJson] to create a properly + /// subclassed [Task] from the [json] + UriDownloadTask.fromJson(super.json) : super.fromJson(); + + @override + UriDownloadTask copyWith({ + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + Uri? directoryUri, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? retries, + int? retriesRemaining, + bool? allowPause, + int? priority, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => UriDownloadTask( + taskId: taskId ?? this.taskId, + url: url ?? this.url, + filename: filename ?? this.filename, + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + post: post ?? postBody, + directoryUri: directoryUri ?? this.directoryUri ?? Uri.base, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + retries: retries ?? this.retries, + allowPause: allowPause ?? this.allowPause, + priority: priority ?? this.priority, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; + + @override + String get taskType => 'UriDownloadTask'; +} + +final class UriUploadTask extends UploadTask with _UriTaskMixin { + /// Creates [UploadTask] from a URI + /// + /// The [fileUri] will can be obtained + /// using [fileUri] (do not access the [filename] property directly). + /// If a [filename] argument is supplied in this constructor, + /// it will be used, otherwise the filename will be derived from the URL + UriUploadTask({ + required Uri fileUri, + super.taskId, + required super.url, + super.urlQueryParameters, + String? filename, + super.headers, + String? httpRequestMethod, + super.post, + super.fileField = 'file', + String? mimeType, + Map? fields, + super.group, + super.updates, + super.requiresWiFi, + super.retries, + super.priority, + super.metaData, + super.displayName, + super.creationTime, + super.options, + super.transferHints, + super.notificationConfig, + super.stallTimeout, + }) : super( + baseDirectory: BaseDirectory.root, + filename: filename != null + ? pack(filename, fileUri) + : fileUri.toString(), + httpRequestMethod: httpRequestMethod ?? 'POST', + mimeType: mimeType ?? 'application/octet-stream', + fields: fields ?? {}, + ) { + assert( + Task.allowedUriSchemes.contains(fileUri.scheme), + 'URI scheme must be one of ${Task.allowedUriSchemes}', + ); + } + + /// Creates [Task] object from JsonMap + /// + /// Only used by subclasses. Use [Task.createFromJson] to create a properly + /// subclassed [Task] from the [json] + UriUploadTask.fromJson(super.json) : super.fromJson(); + + @override + UriUploadTask copyWith({ + Uri? fileUri, + String? taskId, + String? url, + String? filename, + Map? headers, + String? httpRequestMethod, + Object? post, + String? fileField, + String? mimeType, + Map? fields, + String? directory, + BaseDirectory? baseDirectory, + String? group, + Updates? updates, + bool? requiresWiFi, + int? priority, + int? retries, + int? retriesRemaining, + bool? allowPause, + String? metaData, + String? displayName, + DateTime? creationTime, + TaskOptions? options, + Set? transferHints, + TaskNotificationConfig? notificationConfig, + Duration? stallTimeout, + }) => UriUploadTask( + fileUri: fileUri ?? this.fileUri ?? Uri.base, + taskId: taskId ?? this.taskId, + url: url ?? this.url, + filename: filename ?? this.filename, + headers: headers ?? this.headers, + httpRequestMethod: httpRequestMethod ?? this.httpRequestMethod, + post: post as String? ?? this.post, + fileField: fileField ?? this.fileField, + mimeType: mimeType ?? this.mimeType, + fields: fields ?? this.fields, + group: group ?? this.group, + updates: updates ?? this.updates, + requiresWiFi: requiresWiFi ?? this.requiresWiFi, + priority: priority ?? this.priority, + retries: retries ?? this.retries, + metaData: metaData ?? this.metaData, + displayName: displayName ?? this.displayName, + creationTime: creationTime ?? this.creationTime, + options: options ?? this.options, + transferHints: transferHints ?? this.transferHints, + notificationConfig: notificationConfig ?? this.notificationConfig, + stallTimeout: stallTimeout ?? this.stallTimeout, + )..retriesRemaining = retriesRemaining ?? this.retriesRemaining; + + @override + String get taskType => 'UriUploadTask'; +} diff --git a/third_party/background_downloader/lib/src/uri/uri_utils.dart b/third_party/background_downloader/lib/src/uri/uri_utils.dart new file mode 100644 index 0000000..fcacbd1 --- /dev/null +++ b/third_party/background_downloader/lib/src/uri/uri_utils.dart @@ -0,0 +1,603 @@ +/// Object attached to the [FileDownloader]'s `utils` property, used to access +/// URI related utility functions + +library; + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +import '../base_downloader.dart'; +import '../models.dart'; + +import 'package:path/path.dart' as p; + +import '../native_downloader.dart'; +import '../task.dart'; + +/// Base implementation of the utilities related to File, Uri and filePath +/// manipulation. +/// +/// Use the [withDownloader] factory constructor to get the appropriate subclass +/// for the platform you're on +sealed class UriUtils { + final log = Logger('UriUtils'); + final BaseDownloader _downloader; + + UriUtils(BaseDownloader downloader) : _downloader = downloader; + + factory UriUtils.withDownloader(BaseDownloader downloader) => + switch (downloader) { + AndroidDownloader() => _AndroidUriUtils(downloader), + IOSDownloader() => _IOSUriUtils(downloader), + _ => _DesktopUriUtils(downloader), + }; + + /// Opens a directory picker dialog and returns the selected directory's URI. + /// + /// [startLocation] (optional) specifies a [SharedStorage] location to open the picker at. + /// [SharedStorage.images] and [SharedStorage.video] will launch the media + /// picker instead of the file picker, and allow selection of the respective media type. + /// [startLocationUri] (optional) specifies a URI to open the picker at. + /// Only one of [startLocation] or [startLocationUri] should be provided. + /// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission + /// for the selected directory, if the platform supports it. + /// + /// Returns the selected directory's URI, or `null` if the user canceled the operation. + Future pickDirectory({ + SharedStorage? startLocation, + Uri? startLocationUri, + bool persistedUriPermission = false, + }); + + /// Opens a file picker dialog and returns the Uri of the selected file, + /// or null if the user canceled the operation. + /// + /// [startLocation] (optional) specifies a [SharedStorage] location to open the picker at. + /// Only .videos and .images will launch the media picker instead of the file picker. + /// [startLocationUri] (optional) specifies a URI to open the picker at. + /// Only one of [startLocation] or [startLocationUri] should be provided. + /// [allowedExtensions] (optional) specifies a list of file extensions to filter the picker by. + /// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission + /// for the selected files, if the platform supports it. + /// + /// Returns the URI of the selected file, or `null` if the user canceled the operation. + Future pickFile({ + SharedStorage? startLocation, + Uri? startLocationUri, + List? allowedExtensions, + bool persistedUriPermission = false, + }) async { + final list = await pickFiles( + startLocation: startLocation, + startLocationUri: startLocationUri, + allowedExtensions: allowedExtensions, + persistedUriPermission: persistedUriPermission, + multipleAllowed: false, + ); + return list?.isNotEmpty == true ? list!.first : null; + } + + /// Opens a file picker dialog and returns a list of the selected files' URIs, + /// or null if the user canceled the operation. + /// + /// [startLocation] (optional) specifies a [SharedStorage] location to open the picker at. + /// Only .videos and .images will launch the media picker instead of the file picker. + /// [startLocationUri] (optional) specifies a URI to open the picker at. + /// Only one of [startLocation] or [startLocationUri] should be provided. + /// [allowedExtensions] (optional) specifies a list of file extensions to filter the picker by. + /// [multipleAllowed] (optional, defaults to `false`) indicates whether to allow multiple file selection. + /// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission + /// for the selected files, if the platform supports it. + /// + /// Returns a list of the selected files' URIs, or `null` if the user canceled the operation. + Future?> pickFiles({ + SharedStorage? startLocation, + Uri? startLocationUri, + List? allowedExtensions, + bool multipleAllowed = false, + bool persistedUriPermission = false, + }); + + /// Creates a new directory with the given name within the specified parent directory. + /// + /// [parentDirectoryUri] is the URI of the parent directory. + /// [newDirectoryName] is the name of the new directory to create. + /// [persistedUriPermission] (optional, defaults to `false`) indicates whether to take persisted URI permission + /// for the created directory, if the platform supports it. + /// + /// Returns the URI of the newly created directory. + Future createDirectory( + Uri parentDirectoryUri, + String newDirectoryName, { + bool persistedUriPermission = false, + }); + + /// Activate a previously accessed directory or file (applies previously + /// obtained permissions) and return the Uri, or null if this was not + /// possible + /// + /// This is a no-op except on iOS, where it is required to re-activate the + /// permission obtained when setting `persistedUriPermission` to `true`, + /// when using the directory or file picker. In those instances, the returned + /// URI will have a 'urlbookmark' scheme instead of a file scheme, and that + /// bookmark contains security information. When you store that bookmark (for + /// later use) then you must make sure that you still have access to that + /// resource when - for example - using [getFileBytes] to get the data, or + /// to upload a file from a previously selected directory. + /// + /// This method also converts a media:// scheme URI on iOS to a file:// URI, + /// allowing you to access (and delete) it. Media URIs on iOS are generated when + /// using [pickFiles] with a .images or .videos startingLocation (which + /// launches the media picker). Selected media is copied into the Application + /// Support directory, subdirectory "com.bbflight.downloader.media" and + /// returned as a media:// URI. To access the actual copied file, + /// use [activate] + Future activate(Uri uri) async => uri; + + /// Retrieves the file data (bytes) for a given URI. + /// + /// [uri] is the URI of the file. + /// + /// Returns a [Uint8List] containing the file data, or `null` if an error occurred. + Future getFileBytes(Uri uri); + + /// Copy a file from a Uri to a new destination. + /// + /// The [destination] parameter can be a [File] object, a [String] with a + /// filePath, or a [Uri]. + Future copyFile(Uri source, dynamic destination); + + /// Move a file from a Uri to a new destination. + /// + /// The [destination] parameter can be a [File] object, a [String] with a + /// filePath, or a [Uri]. + Future moveFile(Uri source, dynamic destination); + + /// Deletes the file at the given URI. + /// + /// [uri] is the URI of the file to delete. + /// + /// Returns `true` if the file was deleted successfully, `false` otherwise. + Future deleteFile(Uri uri); + + /// Opens the file at [uri] with the given [mimeType]. + /// + /// Returns true if successful, false otherwise. + Future openFile(Uri uri, {String mimeType}); + + /// Move the file represented by the [task] to a shared storage + /// [destination] and potentially a [directory] within that destination. If + /// the [mimeType] is not provided we will attempt to derive it from the + /// [UriTask.fileUri] + /// + /// Returns the Uri of the stored file, or null if not successful. + /// + /// NOTE: on iOS, using [destination] [SharedStorage.images] or + /// [SharedStorage.video] adds the photo or video file to the Photos + /// library. This requires the user to grant permission, and requires the + /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The + /// returned value is NOT a filePath but an identifier. If the full filepath + /// is required, follow the [moveToSharedStorage] call with a call to + /// [pathInSharedStorage], passing the identifier obtained from the call + /// to [moveToSharedStorage] as the filePath parameter. This requires the user to + /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription" + /// key to be set in Info.plist. The returned value is the actual file path + /// of the photo or video in the Photos Library. + /// + /// Platform-dependent, not consistent across all platforms + Future moveToSharedStorage( + DownloadTask task, + SharedStorage destination, { + String directory = '', + String? mimeType, + }) async { + final uri = switch (task) { + final UriTask t => t.fileUri, + _ => Uri.file(await task.filePath()), + }; + return uri != null + ? await moveFileToSharedStorage( + uri, + destination, + directory: directory, + mimeType: mimeType, + ) + : null; + } + + /// Move the file represented by [fileUri] to a shared storage + /// [destination] and potentially a [directory] within that destination. If + /// the [mimeType] is not provided we will attempt to derive it from the + /// [fileUri] extension + /// + /// Returns the URI of the stored file, or null if not successful + /// + /// NOTE: on iOS, using [destination] [SharedStorage.images] or + /// [SharedStorage.video] adds the photo or video file to the Photos + /// library. This requires the user to grant permission, and requires the + /// "NSPhotoLibraryAddUsageDescription" key to be set in Info.plist. The + /// returned value is NOT a filePath but an identifier. If the full filepath + /// is required, follow the [moveToSharedStorage] call with a call to + /// [pathInSharedStorage], passing the identifier obtained from the call + /// to [moveToSharedStorage] as the filePath parameter. This requires the user to + /// grant additional permissions, and requires the "NSPhotoLibraryUsageDescription" + /// key to be set in Info.plist. The returned value is the actual file path + /// of the photo or video in the Photos Library. + /// + /// Platform-dependent, not consistent across all platforms + Future moveFileToSharedStorage( + Uri fileUri, + SharedStorage destination, { + String directory = '', + String? mimeType, + }) async { + assert( + fileUri.scheme == 'file', + 'uri.moveFileToSharedStorage requires a file scheme uri, got $fileUri', + ); + final uriString = await _downloader.moveToSharedStorage( + fileUri.toString(), + destination, + directory, + mimeType, + asUriString: true, + ); + return uriString != null ? Uri.tryParse(uriString) : null; + } + + /// Returns the Uri of the file represented by [fileUri] in shared + /// storage [destination] and potentially a [directory] within that + /// destination. + /// + /// Returns the path to the stored file, or null if not successful + /// + /// See the documentation for [moveToSharedStorage] for special use case + /// on iOS for .images and .video + /// + /// Platform-dependent, not consistent across all platforms + Future pathInSharedStorage( + Uri fileUri, + SharedStorage destination, { + String directory = '', + }) async { + assert( + fileUri.scheme == 'file', + 'uri.pathInSharedStorage requires a file scheme uri, got $fileUri', + ); + final uriString = await _downloader.pathInSharedStorage( + fileUri.toString(), + destination, + directory, + asUriString: true, + ); + return uriString != null ? Uri.tryParse(uriString) : null; + } + + /// Private helper method to determine the destination URI. + Uri _determineDestinationUri(dynamic destination) => switch (destination) { + File() => destination.uri, + String() => Uri.file(destination), + Uri() => destination, + _ => throw ArgumentError( + 'Invalid destination type. Must be File, String, or Uri.', + ), + }; +} + +final class _DesktopUriUtils extends UriUtils { + _DesktopUriUtils(super.downloader); + + @override + Future pickDirectory({ + SharedStorage? startLocation, + Uri? startLocationUri, + bool persistedUriPermission = false, + }) => throw UnimplementedError( + 'pickDirectory not implemented for this platform. ' + 'Use the file_picker package and convert the resulting filePath ' + 'to a URI Uri.file(directoryPath, windows: Platform.isWindows)', + ); + + @override + Future?> pickFiles({ + SharedStorage? startLocation, + Uri? startLocationUri, + List? allowedExtensions, + bool multipleAllowed = false, + bool persistedUriPermission = false, + }) => throw UnimplementedError( + 'pickFiles not implemented for this platform. ' + 'Use the file_picker package and convert the resulting filePath ' + 'to a URI using Uri.file(filepath, windows: Platform.isWindows)', + ); + + @override + Future createDirectory( + Uri parentDirectoryUri, + String newDirectoryName, { + bool persistedUriPermission = false, + }) async { + final parentPath = parentDirectoryUri.toFilePath( + windows: Platform.isWindows, + ); + final cleanedSegments = newDirectoryName + .split(RegExp(r'[\\/]+')) + .where((segment) => segment.isNotEmpty) + .toList(); + final fullPath = p.joinAll([parentPath, ...cleanedSegments]); + final createdDirectory = await Directory(fullPath).create(recursive: true); + return createdDirectory.uri; + } + + @override + Future getFileBytes(Uri uri) async { + try { + final file = File.fromUri(uri); + if (await file.exists()) { + return await file.readAsBytes(); + } else { + log.warning('File does not exist: $uri'); + } + } catch (e) { + log.info('Error getting file bytes for $uri', e); + } + return null; + } + + /// Private helper method to perform the file operation (copy or move). + Future _performFileOperation( + Uri source, + Uri destination, + Future Function(String) operation, + ) async { + if (!source.isScheme('file')) { + log.info('Source must be a file:// URI'); + return null; + } + if (!destination.isScheme('file')) { + throw ArgumentError( + 'Invalid destination type. Must be file:// Uri, is $destination', + ); + } + try { + final destinationFile = File.fromUri(destination); + await destinationFile.parent.create(recursive: true); + await operation(destinationFile.path); + return destination; + } catch (e) { + log.info('Error during file operation from $source to $destination', e); + return null; + } + } + + @override + Future copyFile(Uri source, dynamic destination) async { + final destinationUri = _determineDestinationUri(destination); + return _performFileOperation( + source, + destinationUri, + File.fromUri(source).copy, + ); + } + + @override + Future moveFile(Uri source, dynamic destination) async { + final destinationUri = _determineDestinationUri(destination); + return _performFileOperation( + source, + destinationUri, + File.fromUri(source).rename, + ); + } + + @override + Future deleteFile(Uri uri) async { + try { + final file = File.fromUri(uri); + if (await file.exists()) { + await file.delete(); + return true; + } else { + log.warning('File does not exist: $uri'); + } + } catch (e) { + log.warning('Error deleting file: $uri', e); + } + return false; + } + + @override + Future openFile(Uri uri, {String? mimeType}) async { + try { + final filePath = uri.toFilePath(); + return await _downloader.openFile(null, filePath, mimeType); + } catch (e) { + log.warning('Error opening file: $uri', e); + return false; + } + } +} + +final class _NativeUriUtils extends UriUtils { + final _methodChannel = MethodChannel( + 'com.bbflight.background_downloader.uriutils', + ); + + _NativeUriUtils(super.downloader); + + @override + Future pickDirectory({ + SharedStorage? startLocation, + Uri? startLocationUri, + bool persistedUriPermission = false, + }) async { + final uriString = (await _methodChannel.invokeMethod( + 'pickDirectory', + [ + startLocation?.index, + startLocationUri?.toString(), + persistedUriPermission, + ], + )); + return (uriString != null) ? Uri.parse(uriString) : null; + } + + @override + Future?> pickFiles({ + SharedStorage? startLocation, + Uri? startLocationUri, + List? allowedExtensions, + bool multipleAllowed = false, + bool persistedUriPermission = false, + }) async { + final uriStrings = (await _methodChannel.invokeMethod('pickFiles', [ + startLocation?.index, + startLocationUri?.toString(), + allowedExtensions, + multipleAllowed, + persistedUriPermission, + ])); + // uriStrings can be a list of Strings or just one String, or null + return switch (uriStrings) { + final String uri => [Uri.parse(uri)], + final List? uris => + uris + ?.where((e) => e != null) + .map((e) => Uri.parse(e as String)) + .toList(growable: false), + _ => throw ArgumentError( + 'pickFiles returned invalid value $uriStrings of type ${uriStrings.runtimeType}', + ), + }; + } + + @override + Future createDirectory( + Uri parentDirectoryUri, + String newDirectoryName, { + bool persistedUriPermission = false, + }) async { + final uriString = (await _methodChannel.invokeMethod( + 'createDirectory', + [parentDirectoryUri.toString(), newDirectoryName, persistedUriPermission], + )); + return Uri.parse(uriString!); + } + + /// Retrieves the file data (bytes) for a given URI. + /// + /// [uri] is the URI of the file. + /// + /// Returns a [Uint8List] containing the file data, or `null` if an error occurred. + @override + Future getFileBytes(Uri uri) async { + try { + final result = await _methodChannel.invokeMethod( + 'getFileBytes', + uri.toString(), + ); + return result; + } catch (e) { + log.warning('Error reading file: $e'); + return null; + } + } + + @override + Future copyFile(Uri source, dynamic destination) async { + final destinationUri = _determineDestinationUri(destination); + try { + final result = await _methodChannel.invokeMethod('copyFile', [ + source.toString(), + destinationUri.toString(), + ]); + return result != null ? Uri.parse(result) : null; + } catch (e) { + log.warning('Error copying file from $source to $destinationUri', e); + return null; + } + } + + @override + Future moveFile(Uri source, dynamic destination) async { + final destinationUri = _determineDestinationUri(destination); + try { + final result = await _methodChannel.invokeMethod('moveFile', [ + source.toString(), + destinationUri.toString(), + ]); + return result != null ? Uri.parse(result) : null; + } catch (e) { + log.warning('Error moving file from $source to $destinationUri', e); + return null; + } + } + + @override + Future deleteFile(Uri uri) async { + try { + await _methodChannel.invokeMethod('deleteFile', uri.toString()); + return true; + } catch (e) { + log.warning('Error deleting file at URI $uri: $e'); + return false; + } + } + + @override + Future openFile(Uri uri, {String? mimeType}) async { + try { + await _methodChannel.invokeMethod('openFile', [uri.toString(), mimeType]); + return true; + } catch (e) { + log.warning('Error opening file at URI $uri: $e'); + return false; + } + } +} + +final class _AndroidUriUtils extends _NativeUriUtils { + _AndroidUriUtils(super.downloader); +} + +final class _IOSUriUtils extends _NativeUriUtils { + _IOSUriUtils(super.downloader); + + @override + Future activate(Uri uri) async { + try { + final result = await _methodChannel.invokeMethod( + 'activateUri', + uri.toString(), + ); + return result != null ? Uri.parse(result) : uri; + } catch (e) { + log.fine('Error activating URI $uri: $e'); + } + return null; + } +} + +/// Extensions on String related to Uri and File +extension StringUriExtensions on String { + /// Converts this filePath to a file URI + Uri toFileUri() => + Uri.file(this, windows: defaultTargetPlatform == TargetPlatform.windows); +} + +/// Extensions on Uri related to File and String +extension UriExtensions on Uri { + /// Returns the File represented by this [uri] + File toFile() => File( + toFilePath(windows: defaultTargetPlatform == TargetPlatform.windows), + ); + + /// True if Uri scheme is file + bool get isFileUri => scheme == 'file'; + + /// True if Uri scheme is content + bool get isContentUri => scheme == 'content'; +} diff --git a/third_party/background_downloader/lib/src/utils.dart b/third_party/background_downloader/lib/src/utils.dart new file mode 100644 index 0000000..2e50d2d --- /dev/null +++ b/third_party/background_downloader/lib/src/utils.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'task.dart'; + +final _log = Logger('FileDownloader'); + +/// Return url String composed of the [url] and the +/// [urlQueryParameters], if given +String urlWithQueryParameters( + String url, + Map? urlQueryParameters, +) { + if (urlQueryParameters == null || urlQueryParameters.isEmpty) { + return url; + } + final separator = url.contains('?') ? '&' : '?'; + return '$url$separator${urlQueryParameters.entries.map((e) => '${e.key}=${e.value}').join('&')}'; +} + +/// Parses the range in a Range header, and returns a Pair representing +/// the range. The format needs to be "bytes=10-20" +/// +/// A missing lower range is substituted with 0, and a missing upper +/// range with null. If the string cannot be parsed, returns (0, null) +(int, int?) parseRange(String rangeStr) { + final regex = RegExp(r'bytes=(\d*)-(\d*)'); + final match = regex.firstMatch(rangeStr); + if (match == null) { + return (0, null); + } + + final start = int.tryParse(match.group(1) ?? '') ?? 0; + final end = int.tryParse(match.group(2) ?? ''); + return (start, end); +} + +/// Returns the content length extracted from the [responseHeaders], or from +/// the [task] headers +int getContentLength(Map responseHeaders, Task task) { + // if response provides contentLength, return it + final contentLength = int.tryParse( + responseHeaders['Content-Length'] ?? + responseHeaders['content-length'] ?? + '-1', + ); + if (contentLength != null && contentLength != -1) { + return contentLength; + } + // try extracting it from Range header + final taskRangeHeader = task.headers['Range'] ?? task.headers['range'] ?? ''; + final taskRange = parseRange(taskRangeHeader); + if (taskRange.$2 != null) { + final rangeLength = taskRange.$2! - taskRange.$1 + 1; + _log.finest( + 'TaskId ${task.taskId} contentLength set to $rangeLength based on Range header', + ); + return rangeLength; + } + // try extracting it from a special "Known-Content-Length" header + final knownLength = + int.tryParse( + task.headers['Known-Content-Length'] ?? + task.headers['known-content-length'] ?? + '-1', + ) ?? + -1; + if (knownLength != -1) { + _log.finest( + 'TaskId ${task.taskId} contentLength set to $knownLength based on Known-Content-Length header', + ); + } else { + _log.finest('TaskId ${task.taskId} contentLength undetermined'); + } + return knownLength; +} + +/// Returns a copy of the [task] with the [Task.filename] property changed +/// to the filename suggested by the server, or derived from the url, or +/// unchanged. +/// +/// If [unique] is true, the filename is guaranteed not to already exist. This +/// is accomplished by adding a suffix to the suggested filename with a number, +/// e.g. "data (2).txt" +/// +/// The server-suggested filename is obtained from the [responseHeaders] entry +/// "Content-Disposition" according to RFC6266, or the last path segment of the +/// URL, or leaves the filename unchanged +Future taskWithSuggestedFilename( + DownloadTask task, + Map responseHeaders, + bool unique, +) { + /// Returns [DownloadTask] with a filename similar to the one + /// supplied, but unused. + /// + /// If [unique], filename will sequence up in "filename (8).txt" format, + /// otherwise returns the [task] + Future uniqueFilename(DownloadTask task, bool unique) async { + if (!unique) { + return task; + } + final sequenceRegEx = RegExp(r'\((\d+)\)\.?[^.]*$'); + final extensionRegEx = RegExp(r'\.[^.]*$'); + var newTask = task; + var filePath = await newTask.filePath(); + var exists = await File(filePath).exists(); + while (exists) { + final extension = + extensionRegEx.firstMatch(newTask.filename)?.group(0) ?? ''; + final match = sequenceRegEx.firstMatch(newTask.filename); + final newSequence = int.parse(match?.group(1) ?? "0") + 1; + final newFilename = match == null + ? '${path.basenameWithoutExtension(newTask.filename)} ($newSequence)$extension' + : '${newTask.filename.substring(0, match.start - 1)} ($newSequence)$extension'; + newTask = newTask.copyWith(filename: newFilename); + filePath = await newTask.filePath(); + exists = await File(filePath).exists(); + } + return newTask; + } + + // start of main function + try { + final disposition = responseHeaders.entries + .firstWhere( + (element) => element.key.toLowerCase() == 'content-disposition', + ) + .value; + // Try filename*=UTF-8'language'"encodedFilename" + final encodedFilenameRegEx = RegExp( + 'filename\\*=\\s*([^\']+)\'([^\']*)\'"?([^"]+)"?', + caseSensitive: false, + ); + var match = encodedFilenameRegEx.firstMatch(disposition); + if (match != null && + match.group(1)?.isNotEmpty == true && + match.group(3)?.isNotEmpty == true) { + try { + final suggestedFilename = match.group(1)?.toUpperCase() == 'UTF-8' + ? Uri.decodeComponent(match.group(3)!) + : match.group(3)!; + return uniqueFilename( + task.copyWith(filename: suggestedFilename), + unique, + ); + } on ArgumentError { + _log.finest( + 'Could not interpret suggested filename (UTF-8 url encoded) ${match.group(3)}', + ); + } + } + // Try filename="filename" + final plainFilenameRegEx = RegExp( + r'filename=\s*"?([^"]+)"?.*$', + caseSensitive: false, + ); + match = plainFilenameRegEx.firstMatch(disposition); + if (match != null && match.group(1)?.isNotEmpty == true) { + return uniqueFilename(task.copyWith(filename: match.group(1)), unique); + } + } catch (_) {} + _log.finest('Could not determine suggested filename from server'); + // Try filename derived from last path segment of the url + try { + final suggestedFilename = Uri.parse(task.url).pathSegments.last; + return uniqueFilename(task.copyWith(filename: suggestedFilename), unique); + } catch (_) {} + _log.finest('Could not parse URL pathSegment for suggested filename'); + // if everything fails, return the task with unchanged filename + // except for possibly making it unique + return uniqueFilename(task, unique); +} diff --git a/third_party/background_downloader/lib/src/web_downloader.dart b/third_party/background_downloader/lib/src/web_downloader.dart new file mode 100644 index 0000000..5660ab0 --- /dev/null +++ b/third_party/background_downloader/lib/src/web_downloader.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +import 'base_downloader.dart'; +import 'models.dart'; +import 'task.dart'; + +/// This is a non-functional stub +/// +/// This file is conditionally imported when the compilation target for an +/// application is the Web platform. Without the conditional import, the +/// regular [DesktopDownloader] from desktop_downloader.dart would be used, +/// and it does not compile for Web. This stub prevents that compilation error, +/// but it is not an actual implementation for the Web. + +final class DesktopDownloader extends BaseDownloader { + static const defaultMaxRedirects = 10; + static var httpClient = http.Client(); + static Duration? requestTimeout; + static var proxy = {}; // 'address' and 'port' + static var bypassTLSCertificateValidation = false; + static var mtlsConfigs = []; + + /// Sets the HTTP client used for requests + /// + /// This is a stub implementation + static void setHttpClient( + Duration? requestTimeout, + Map proxy, + bool bypassTLSCertificateValidation, [ + List? mtlsConfigs, + ]) { + requestTimeout = requestTimeout; + proxy = proxy; + bypassTLSCertificateValidation = bypassTLSCertificateValidation; + } + + /// Returns an [http.Client] configured for the given [url]. + static http.Client httpClientForUrl(String? url) => httpClient; + + /// Returns an [http.Client] configured for the given [host]. + static http.Client httpClientForHost(String? host) => httpClient; + + @override + Future cancelPlatformTasksWithIds(List taskIds) { + throw UnimplementedError(); + } + + @override + Future> pauseAll({ + Iterable? tasks, + String? group, + }) { + throw UnimplementedError(); + } + + @override + Future> pauseTaskList(Iterable tasksToPause) { + throw UnimplementedError(); + } + + @override + Future<(String, String)> configureItem((String, dynamic) configItem) { + throw UnimplementedError(); + } + + @override + Future getTaskTimeout() { + throw UnimplementedError(); + } + + @override + Future openFile(Task? task, String? filePath, String? mimeType) { + throw UnimplementedError(); + } + + @override + Future pause(Task task) { + throw UnimplementedError(); + } + + @override + platformConfig({globalConfig, androidConfig, iOSConfig, desktopConfig}) { + throw UnimplementedError(); + } + + @override + Future> popUndeliveredData(Undelivered dataType) { + throw UnimplementedError(); + } + + @override + Future setForceFailPostOnBackgroundChannel(bool value) { + throw UnimplementedError(); + } + + @override + Future testSuggestedFilename( + DownloadTask task, + String contentDisposition, + ) { + throw UnimplementedError(); + } + + @override + Future> enqueueAll(Iterable tasks) { + throw UnimplementedError(); + } +} + +/// The default maximum number of redirects to follow for HTTP requests +const defaultMaxRedirects = DesktopDownloader.defaultMaxRedirects; diff --git a/third_party/background_downloader/lib/src/widgets/transfer_button.dart b/third_party/background_downloader/lib/src/widgets/transfer_button.dart new file mode 100644 index 0000000..21a0700 --- /dev/null +++ b/third_party/background_downloader/lib/src/widgets/transfer_button.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../task.dart'; +import '../transfer.dart'; + +/// An adaptive action button for a [Transfer]. +/// +/// Dynamically toggles between Pause, Resume, Retry, and Complete actions +/// based on the transfer's current [TaskStatus]. +class TransferButton extends StatelessWidget { + /// The [Transfer] to control. + final Transfer transfer; + + /// Icon size in logical pixels (defaults to `24.0`). + final double iconSize; + + /// Padding around the icon button. + final EdgeInsetsGeometry padding; + + /// Color override for the icon. + final Color? color; + + /// Optional callback invoked when the user cancels the transfer. + /// + /// If omitted, defaults to calling [Transfer.cancel]. + final VoidCallback? onCancel; + + /// Creates a reactive [TransferButton]. + const TransferButton({ + super.key, + required this.transfer, + this.iconSize = 24.0, + this.padding = const EdgeInsets.all(8.0), + this.color, + this.onCancel, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ValueListenableBuilder( + valueListenable: transfer.statusNotifier, + builder: (context, status, _) { + final canPause = + transfer.task is DownloadTask && transfer.task.allowPause; + return switch (status) { + .running || .enqueued => + canPause + ? IconButton( + icon: Icon(Icons.pause_circle_outline, size: iconSize), + color: color ?? theme.colorScheme.primary, + padding: padding, + tooltip: 'Pause', + onPressed: () => transfer.pause(), + ) + : IconButton( + icon: Icon(Icons.cancel_outlined, size: iconSize), + color: color ?? theme.colorScheme.error, + padding: padding, + tooltip: 'Cancel', + onPressed: onCancel ?? () => transfer.cancel(), + ), + .paused || .waitingToRetry => IconButton( + icon: Icon(Icons.play_circle_outline, size: iconSize), + color: color ?? Colors.orange, + padding: padding, + tooltip: 'Resume', + onPressed: () => transfer.resume(), + ), + .failed || .notFound => IconButton( + icon: Icon(Icons.refresh, size: iconSize), + color: color ?? theme.colorScheme.error, + padding: padding, + tooltip: 'Retry', + onPressed: () => transfer.resume(), + ), + .complete => Icon( + Icons.check_circle, + size: iconSize, + color: color ?? Colors.green, + ), + .canceled => IconButton( + icon: Icon(Icons.replay, size: iconSize), + color: color ?? theme.colorScheme.outline, + padding: padding, + tooltip: 'Restart', + onPressed: () => transfer.resume(), + ), + }; + }, + ); + } +} + +/// Alias for [TransferButton] for backward compatibility +typedef FileTransferButton = TransferButton; diff --git a/third_party/background_downloader/lib/src/widgets/transfer_list_tile.dart b/third_party/background_downloader/lib/src/widgets/transfer_list_tile.dart new file mode 100644 index 0000000..26ecc52 --- /dev/null +++ b/third_party/background_downloader/lib/src/widgets/transfer_list_tile.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../task.dart'; +import '../transfer.dart'; +import 'transfer_button.dart'; +import 'transfer_progress_bar.dart'; + +/// A plug-and-play [ListTile] displaying a [Transfer] with +/// filename, progress bar, transfer speed, and action controls. +class TransferListTile extends StatelessWidget { + /// The [Transfer] to display and interact with. + final Transfer transfer; + + /// Optional custom leading widget. If null, a circular icon based on task type is rendered. + final Widget? leading; + + /// Optional custom trailing widget. If null, a [TransferButton] is rendered. + final Widget? trailing; + + /// Optional callback invoked when the tile is tapped. + final VoidCallback? onTap; + + /// Whether to show live network speed in the progress subtitle. Defaults to `true`. + final bool showSpeed; + + /// Whether to show percentage text in the progress subtitle. Defaults to `true`. + final bool showPercentage; + + /// Creates a [TransferListTile]. + const TransferListTile({ + super.key, + required this.transfer, + this.leading, + this.trailing, + this.onTap, + this.showSpeed = true, + this.showPercentage = true, + }); + + @override + Widget build(BuildContext context) { + final title = transfer.task.displayName.isNotEmpty + ? transfer.task.displayName + : transfer.task.filename; + + return ListTile( + leading: leading ?? _defaultLeading(context), + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6.0), + child: TransferProgressBar( + transfer: transfer, + showSpeed: showSpeed, + showPercentage: showPercentage, + ), + ), + trailing: trailing ?? TransferButton(transfer: transfer), + onTap: onTap, + ); + } + + Widget _defaultLeading(BuildContext context) { + final theme = Theme.of(context); + final isUpload = transfer.task is UploadTask; + + return CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + child: Icon(isUpload ? Icons.upload_file : Icons.download, size: 20), + ); + } +} + +/// Alias for [TransferListTile] for backward compatibility +typedef FileTransferListTile = TransferListTile; diff --git a/third_party/background_downloader/lib/src/widgets/transfer_progress_bar.dart b/third_party/background_downloader/lib/src/widgets/transfer_progress_bar.dart new file mode 100644 index 0000000..f335dec --- /dev/null +++ b/third_party/background_downloader/lib/src/widgets/transfer_progress_bar.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../transfer.dart'; + +/// A reactive progress bar widget bound to a [Transfer]. +/// +/// Automatically updates progress, percentage, transfer speed, and status +/// without requiring parent widget rebuilds. +class TransferProgressBar extends StatelessWidget { + /// The [Transfer] to observe. + final Transfer transfer; + + /// Whether to display percentage text (e.g. `'45%'`). Defaults to `true`. + final bool showPercentage; + + /// Whether to display the text status badge (e.g. `'Transferring'`, `'Waiting for Wi-Fi'`). Defaults to `true`. + final bool showStatusText; + + /// Whether to display live network transfer speed (e.g. `'1.2 MB/s'`). Defaults to `true`. + final bool showSpeed; + + /// Custom color override for the progress bar. If null, a color based on [TaskStatus] is used. + final Color? progressColor; + + /// Background color of the progress bar track. + final Color? backgroundColor; + + /// Custom [TextStyle] for the status, speed, and percentage labels. + final TextStyle? textStyle; + + /// Height of the progress bar in logical pixels. Defaults to `6.0`. + final double height; + + /// Creates a reactive [TransferProgressBar]. + const TransferProgressBar({ + super.key, + required this.transfer, + this.showPercentage = true, + this.showStatusText = true, + this.showSpeed = true, + this.progressColor, + this.backgroundColor, + this.textStyle, + this.height = 6.0, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveTextStyle = + textStyle ?? theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12); + + return ValueListenableBuilder( + valueListenable: transfer.statusNotifier, + builder: (context, status, _) => + ValueListenableBuilder( + valueListenable: transfer.holdReasonNotifier, + builder: (context, holdReason, _) => ValueListenableBuilder( + valueListenable: transfer.progressNotifier, + builder: (context, progress, _) => ValueListenableBuilder( + valueListenable: transfer.networkSpeedNotifier, + builder: (context, speed, _) { + final effectiveColor = _colorForStatus( + status, + holdReason, + theme, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(height / 2), + child: SizedBox( + height: height, + child: LinearProgressIndicator( + value: status == TaskStatus.complete + ? 1.0 + : (progress != null && progress >= 0.0 + ? progress + : null), + color: effectiveColor, + backgroundColor: + backgroundColor ?? + theme.colorScheme.surfaceContainerHighest, + ), + ), + ), + if (showPercentage || showStatusText || showSpeed) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (showStatusText) + Text( + _statusLabel(status, holdReason), + style: effectiveTextStyle.copyWith( + color: effectiveColor, + fontWeight: FontWeight.w500, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showSpeed && + speed > 0 && + status == TaskStatus.running) ...[ + Text( + _formatSpeed(speed), + style: effectiveTextStyle.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + ], + if (showPercentage) ...[ + if (status == TaskStatus.complete) + Text( + '100%', + style: effectiveTextStyle.copyWith( + fontWeight: FontWeight.bold, + ), + ) + else if (progress != null && progress >= 0.0) + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: effectiveTextStyle.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ), + ], + ), + ], + ], + ); + }, + ), + ), + ), + ); + } + + Color _colorForStatus( + TaskStatus status, + TransferHoldReason holdReason, + ThemeData theme, + ) { + if (progressColor != null) return progressColor!; + if (holdReason != TransferHoldReason.none) { + return Colors.orange; + } + return switch (status) { + .complete => Colors.green, + .failed || .notFound => theme.colorScheme.error, + .paused || .waitingToRetry => Colors.orange, + .running || .enqueued => theme.colorScheme.primary, + .canceled => theme.colorScheme.outline, + }; + } + + String _statusLabel(TaskStatus status, TransferHoldReason holdReason) => + switch (holdReason) { + .waitingForWiFi => 'Waiting for Wi-Fi', + .offline => 'Waiting for network', + .none => switch (status) { + .enqueued => 'Enqueued', + .running => 'Transferring', + .complete => 'Complete', + .paused => 'Paused', + .waitingToRetry => 'Waiting to retry', + .failed => 'Failed', + .notFound => 'Not found', + .canceled => 'Canceled', + }, + }; + + String _formatSpeed(double mbPerSec) => switch (mbPerSec) { + <= 0 => '', + >= 1.0 => '${mbPerSec.toStringAsFixed(1)} MB/s', + _ => '${(mbPerSec * 1000).round()} kB/s', + }; +} + +/// Alias for [TransferProgressBar] for backward compatibility +typedef FileTransferProgressBar = TransferProgressBar; diff --git a/third_party/background_downloader/pubspec.yaml b/third_party/background_downloader/pubspec.yaml new file mode 100644 index 0000000..49cd527 --- /dev/null +++ b/third_party/background_downloader/pubspec.yaml @@ -0,0 +1,49 @@ +name: background_downloader +description: A multi-platform background file downloader and uploader. Define the task, enqueue and monitor progress + +version: 9.6.5 +repository: https://github.com/781flyingdutchman/background_downloader + +environment: + sdk: ^3.13.0 + flutter: '>=3.47.0' + +dependencies: + flutter: + sdk: flutter + logging: ^1.0.2 + http: ^1.1.0 + path_provider: ^2.0.2 + path: ^1.8.1 + async: ^2.6.0 + mime: ^2.0.0 + collection: ^1.15.0 + connectivity_plus: '>=6.1.3 <8.0.0' + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +platforms: + android: + ios: + linux: + macos: + windows: + + +flutter: + plugin: + platforms: + android: + package: com.bbflight.background_downloader + pluginClass: BDPlugin + ios: + pluginClass: BackgroundDownloaderPlugin + +funding: + - https://github.com/sponsors/781flyingdutchman + +false_secrets: + - /test/mtls_test.dart \ No newline at end of file diff --git a/third_party/libvlc/NOTICE.txt b/third_party/libvlc/NOTICE.txt new file mode 100644 index 0000000..60701e1 --- /dev/null +++ b/third_party/libvlc/NOTICE.txt @@ -0,0 +1,30 @@ +# LibVLC Android 3.7.7 + +The application dynamically links the unmodified Maven Central artifact +`org.videolan.android:libvlc-all:3.7.7`. Its Java source JAR and original Maven POM +are retained here. The binary AAR is fetched by Gradle and is not committed or +copied into the application source ZIP. + +- Upstream project: https://code.videolan.org/videolan/libvlcjni +- Published sources: https://repo.maven.apache.org/maven2/org/videolan/android/libvlc-all/3.7.7/libvlc-all-3.7.7-sources.jar +- Android source/build tree: https://github.com/videolan/vlc-android/tree/libvlc-3.7.7 +- Android tag commit: `7a7c8f14be6759f9bb1b9a934fd086259473e20e` +- Pinned JNI source commit from that tree's `buildsystem/compile.sh`: + `0b8dc65efb203c86a0476bc337ddd99ecf1c0ef6` +- Native VLC source and contributed dependencies are pinned by that JNI tree's + `buildsystem/get-vlc.sh` and build/contribution recipes. Follow the upstream + Android compilation guide and retain its license selection when rebuilding. + +Rebuild/relink ReelBox with a replacement library by changing the dependency in +`android/app/build.gradle.kts`, obtaining its corresponding sources, then building +with a compatible Android SDK/NDK and Flutter toolchain. Sign a +modified build with your own Android key; the distributed application imposes no +additional restriction on modified builds. No private release signing key is +required to rebuild or independently install the application. + +Full LGPL-2.1 and attribution notices are in `assets/licenses/` and are included in +the APK and Flutter's in-app licenses list. ReelBox source is GPL-3.0; native +components retain their upstream licenses. This is not an official VideoLAN app +and includes no VideoLAN or KMPlayer branding. The LibVLC binary has not been +modified. Preserve this file, the original POM and license notices in source +redistributions. diff --git a/third_party/libvlc/libvlc-all-3.7.7-sources.jar b/third_party/libvlc/libvlc-all-3.7.7-sources.jar new file mode 100644 index 0000000..6de9965 Binary files /dev/null and b/third_party/libvlc/libvlc-all-3.7.7-sources.jar differ diff --git a/third_party/libvlc/libvlc-all-3.7.7.pom b/third_party/libvlc/libvlc-all-3.7.7.pom new file mode 100644 index 0000000..97e4c40 --- /dev/null +++ b/third_party/libvlc/libvlc-all-3.7.7.pom @@ -0,0 +1,57 @@ + + + + + + + + 4.0.0 + org.videolan.android + libvlc-all + 3.7.7 + aar + LibVLC-Android + Android bindings and API for VLC + https://code.videolan.org/videolan/libvlcjni + 2021 + + + GNU Lesser General Public License, version 2.1 + https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html + repo + + + + + videolan + VideoLAN + https://www.videolan.org/ + + + + scm:git:https://code.videolan.org/videolan/libvlcjni.git + scm:git:https://code.videolan.org/videolan/libvlcjni.git + https://code.videolan.org/videolan/libvlcjni + + + + androidx.annotation + annotation + 1.7.1 + compile + + + androidx.legacy + legacy-support-v4 + 1.0.0 + compile + + + org.jetbrains.kotlin + kotlin-stdlib + 2.2.10 + compile + + + diff --git a/third_party/licenses/dart/android_file_picker.txt b/third_party/licenses/dart/android_file_picker.txt new file mode 100644 index 0000000..feb644f --- /dev/null +++ b/third_party/licenses/dart/android_file_picker.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/android_intent_plus.txt b/third_party/licenses/dart/android_intent_plus.txt new file mode 100644 index 0000000..7b99542 --- /dev/null +++ b/third_party/licenses/dart/android_intent_plus.txt @@ -0,0 +1,27 @@ +Copyright 2017 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/archive.txt b/third_party/licenses/dart/archive.txt new file mode 100644 index 0000000..1c466c4 --- /dev/null +++ b/third_party/licenses/dart/archive.txt @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2013-2021 Brendan Duncan. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/args.txt b/third_party/licenses/dart/args.txt new file mode 100644 index 0000000..ab3bfa0 --- /dev/null +++ b/third_party/licenses/dart/args.txt @@ -0,0 +1,27 @@ +Copyright 2013, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/async.txt b/third_party/licenses/dart/async.txt new file mode 100644 index 0000000..dbd2843 --- /dev/null +++ b/third_party/licenses/dart/async.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/background_downloader.txt b/third_party/licenses/dart/background_downloader.txt new file mode 100644 index 0000000..b87579b --- /dev/null +++ b/third_party/licenses/dart/background_downloader.txt @@ -0,0 +1,37 @@ +Copyright 2022 BBFlight LLC + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +This software includes a modified version of package localstore (https://pub.dev/packages/localstore/versions/1.3.5). The license for that software is: + +MIT License + +Copyright (c) 2021 Hanoi University of Mining and Geology, Vietnam. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/boolean_selector.txt b/third_party/licenses/dart/boolean_selector.txt new file mode 100644 index 0000000..ac7d7af --- /dev/null +++ b/third_party/licenses/dart/boolean_selector.txt @@ -0,0 +1,27 @@ +Copyright 2016, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/characters.txt b/third_party/licenses/dart/characters.txt new file mode 100644 index 0000000..7670007 --- /dev/null +++ b/third_party/licenses/dart/characters.txt @@ -0,0 +1,27 @@ +Copyright 2019, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/clock.txt b/third_party/licenses/dart/clock.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/third_party/licenses/dart/clock.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third_party/licenses/dart/code_assets.txt b/third_party/licenses/dart/code_assets.txt new file mode 100644 index 0000000..2b76736 --- /dev/null +++ b/third_party/licenses/dart/code_assets.txt @@ -0,0 +1,27 @@ +Copyright 2025, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/collection.txt b/third_party/licenses/dart/collection.txt new file mode 100644 index 0000000..dbd2843 --- /dev/null +++ b/third_party/licenses/dart/collection.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/connectivity_plus.txt b/third_party/licenses/dart/connectivity_plus.txt new file mode 100644 index 0000000..7b99542 --- /dev/null +++ b/third_party/licenses/dart/connectivity_plus.txt @@ -0,0 +1,27 @@ +Copyright 2017 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/connectivity_plus_platform_interface.txt b/third_party/licenses/dart/connectivity_plus_platform_interface.txt new file mode 100644 index 0000000..687fb49 --- /dev/null +++ b/third_party/licenses/dart/connectivity_plus_platform_interface.txt @@ -0,0 +1,27 @@ +Copyright 2020 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/cross_file.txt b/third_party/licenses/dart/cross_file.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/cross_file.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/crypto.txt b/third_party/licenses/dart/crypto.txt new file mode 100644 index 0000000..633672a --- /dev/null +++ b/third_party/licenses/dart/crypto.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/dbus.txt b/third_party/licenses/dart/dbus.txt new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/third_party/licenses/dart/dbus.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/licenses/dart/dio.txt b/third_party/licenses/dart/dio.txt new file mode 100644 index 0000000..cef745f --- /dev/null +++ b/third_party/licenses/dart/dio.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018 Wen Du (wendux) +Copyright (c) 2022 The CFUG Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/dio_web_adapter.txt b/third_party/licenses/dart/dio_web_adapter.txt new file mode 100644 index 0000000..cef745f --- /dev/null +++ b/third_party/licenses/dart/dio_web_adapter.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018 Wen Du (wendux) +Copyright (c) 2022 The CFUG Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/fake_async.txt b/third_party/licenses/dart/fake_async.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/third_party/licenses/dart/fake_async.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third_party/licenses/dart/ffi.txt b/third_party/licenses/dart/ffi.txt new file mode 100644 index 0000000..7670007 --- /dev/null +++ b/third_party/licenses/dart/ffi.txt @@ -0,0 +1,27 @@ +Copyright 2019, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/ffi_leak_tracker.txt b/third_party/licenses/dart/ffi_leak_tracker.txt new file mode 100644 index 0000000..c5086af --- /dev/null +++ b/third_party/licenses/dart/ffi_leak_tracker.txt @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Halil Durmus + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/file.txt b/third_party/licenses/dart/file.txt new file mode 100644 index 0000000..076334f --- /dev/null +++ b/third_party/licenses/dart/file.txt @@ -0,0 +1,26 @@ +Copyright 2017, the Dart project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/third_party/licenses/dart/file_picker.txt b/third_party/licenses/dart/file_picker.txt new file mode 100644 index 0000000..e8b4b28 --- /dev/null +++ b/third_party/licenses/dart/file_picker.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/file_picker_darwin.txt b/third_party/licenses/dart/file_picker_darwin.txt new file mode 100644 index 0000000..feb644f --- /dev/null +++ b/third_party/licenses/dart/file_picker_darwin.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/file_picker_linux.txt b/third_party/licenses/dart/file_picker_linux.txt new file mode 100644 index 0000000..feb644f --- /dev/null +++ b/third_party/licenses/dart/file_picker_linux.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/file_picker_platform_interface.txt b/third_party/licenses/dart/file_picker_platform_interface.txt new file mode 100644 index 0000000..feb644f --- /dev/null +++ b/third_party/licenses/dart/file_picker_platform_interface.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/file_picker_web.txt b/third_party/licenses/dart/file_picker_web.txt new file mode 100644 index 0000000..feb644f --- /dev/null +++ b/third_party/licenses/dart/file_picker_web.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/fixnum.txt b/third_party/licenses/dart/fixnum.txt new file mode 100644 index 0000000..162572a --- /dev/null +++ b/third_party/licenses/dart/fixnum.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/flutter.txt b/third_party/licenses/dart/flutter.txt new file mode 100644 index 0000000..aea51a3 --- /dev/null +++ b/third_party/licenses/dart/flutter.txt @@ -0,0 +1,25 @@ +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/flutter_lints.txt b/third_party/licenses/dart/flutter_lints.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/flutter_lints.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/flutter_localizations.txt b/third_party/licenses/dart/flutter_localizations.txt new file mode 100644 index 0000000..aea51a3 --- /dev/null +++ b/third_party/licenses/dart/flutter_localizations.txt @@ -0,0 +1,25 @@ +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/flutter_test.txt b/third_party/licenses/dart/flutter_test.txt new file mode 100644 index 0000000..aea51a3 --- /dev/null +++ b/third_party/licenses/dart/flutter_test.txt @@ -0,0 +1,25 @@ +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/flutter_web_plugins.txt b/third_party/licenses/dart/flutter_web_plugins.txt new file mode 100644 index 0000000..aea51a3 --- /dev/null +++ b/third_party/licenses/dart/flutter_web_plugins.txt @@ -0,0 +1,25 @@ +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/hooks.txt b/third_party/licenses/dart/hooks.txt new file mode 100644 index 0000000..2b76736 --- /dev/null +++ b/third_party/licenses/dart/hooks.txt @@ -0,0 +1,27 @@ +Copyright 2025, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/http.txt b/third_party/licenses/dart/http.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/http.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/http_parser.txt b/third_party/licenses/dart/http_parser.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/http_parser.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/image.txt b/third_party/licenses/dart/image.txt new file mode 100644 index 0000000..49f0f88 --- /dev/null +++ b/third_party/licenses/dart/image.txt @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2013-2022 Brendan Duncan. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/intl.txt b/third_party/licenses/dart/intl.txt new file mode 100644 index 0000000..7efe25f --- /dev/null +++ b/third_party/licenses/dart/intl.txt @@ -0,0 +1,27 @@ +Copyright 2013, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/jni.txt b/third_party/licenses/dart/jni.txt new file mode 100644 index 0000000..33474aa --- /dev/null +++ b/third_party/licenses/dart/jni.txt @@ -0,0 +1,27 @@ +Copyright 2022, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/jni_flutter.txt b/third_party/licenses/dart/jni_flutter.txt new file mode 100644 index 0000000..9035a41 --- /dev/null +++ b/third_party/licenses/dart/jni_flutter.txt @@ -0,0 +1,27 @@ +Copyright 2026, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/jni_util.txt b/third_party/licenses/dart/jni_util.txt new file mode 100644 index 0000000..9035a41 --- /dev/null +++ b/third_party/licenses/dart/jni_util.txt @@ -0,0 +1,27 @@ +Copyright 2026, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/leak_tracker.txt b/third_party/licenses/dart/leak_tracker.txt new file mode 100644 index 0000000..33474aa --- /dev/null +++ b/third_party/licenses/dart/leak_tracker.txt @@ -0,0 +1,27 @@ +Copyright 2022, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/leak_tracker_flutter_testing.txt b/third_party/licenses/dart/leak_tracker_flutter_testing.txt new file mode 100644 index 0000000..33474aa --- /dev/null +++ b/third_party/licenses/dart/leak_tracker_flutter_testing.txt @@ -0,0 +1,27 @@ +Copyright 2022, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/leak_tracker_testing.txt b/third_party/licenses/dart/leak_tracker_testing.txt new file mode 100644 index 0000000..33474aa --- /dev/null +++ b/third_party/licenses/dart/leak_tracker_testing.txt @@ -0,0 +1,27 @@ +Copyright 2022, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/lints.txt b/third_party/licenses/dart/lints.txt new file mode 100644 index 0000000..a217815 --- /dev/null +++ b/third_party/licenses/dart/lints.txt @@ -0,0 +1,27 @@ +Copyright 2021, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/logging.txt b/third_party/licenses/dart/logging.txt new file mode 100644 index 0000000..ab3bfa0 --- /dev/null +++ b/third_party/licenses/dart/logging.txt @@ -0,0 +1,27 @@ +Copyright 2013, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/matcher.txt b/third_party/licenses/dart/matcher.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/matcher.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/material_color_utilities.txt b/third_party/licenses/dart/material_color_utilities.txt new file mode 100644 index 0000000..f67fa86 --- /dev/null +++ b/third_party/licenses/dart/material_color_utilities.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/licenses/dart/media_kit.txt b/third_party/licenses/dart/media_kit.txt new file mode 100644 index 0000000..c4c95d9 --- /dev/null +++ b/third_party/licenses/dart/media_kit.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 & onwards Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/media_kit_libs_android_video.txt b/third_party/licenses/dart/media_kit_libs_android_video.txt new file mode 100644 index 0000000..c4c95d9 --- /dev/null +++ b/third_party/licenses/dart/media_kit_libs_android_video.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 & onwards Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/media_kit_video.txt b/third_party/licenses/dart/media_kit_video.txt new file mode 100644 index 0000000..c4c95d9 --- /dev/null +++ b/third_party/licenses/dart/media_kit_video.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 & onwards Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/meta.txt b/third_party/licenses/dart/meta.txt new file mode 100644 index 0000000..ac7d7af --- /dev/null +++ b/third_party/licenses/dart/meta.txt @@ -0,0 +1,27 @@ +Copyright 2016, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/mime.txt b/third_party/licenses/dart/mime.txt new file mode 100644 index 0000000..dbd2843 --- /dev/null +++ b/third_party/licenses/dart/mime.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/nm.txt b/third_party/licenses/dart/nm.txt new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/third_party/licenses/dart/nm.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/licenses/dart/objective_c.txt b/third_party/licenses/dart/objective_c.txt new file mode 100644 index 0000000..b03a788 --- /dev/null +++ b/third_party/licenses/dart/objective_c.txt @@ -0,0 +1,27 @@ +Copyright 2024, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/package_config.txt b/third_party/licenses/dart/package_config.txt new file mode 100644 index 0000000..7670007 --- /dev/null +++ b/third_party/licenses/dart/package_config.txt @@ -0,0 +1,27 @@ +Copyright 2019, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/package_info_plus.txt b/third_party/licenses/dart/package_info_plus.txt new file mode 100644 index 0000000..7b99542 --- /dev/null +++ b/third_party/licenses/dart/package_info_plus.txt @@ -0,0 +1,27 @@ +Copyright 2017 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/package_info_plus_platform_interface.txt b/third_party/licenses/dart/package_info_plus_platform_interface.txt new file mode 100644 index 0000000..7b99542 --- /dev/null +++ b/third_party/licenses/dart/package_info_plus_platform_interface.txt @@ -0,0 +1,27 @@ +Copyright 2017 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path.txt b/third_party/licenses/dart/path.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/path.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path_provider.txt b/third_party/licenses/dart/path_provider.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/path_provider.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path_provider_android.txt b/third_party/licenses/dart/path_provider_android.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/path_provider_android.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path_provider_foundation.txt b/third_party/licenses/dart/path_provider_foundation.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/path_provider_foundation.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path_provider_linux.txt b/third_party/licenses/dart/path_provider_linux.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/path_provider_linux.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path_provider_platform_interface.txt b/third_party/licenses/dart/path_provider_platform_interface.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/path_provider_platform_interface.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/path_provider_windows.txt b/third_party/licenses/dart/path_provider_windows.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/path_provider_windows.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/petitparser.txt b/third_party/licenses/dart/petitparser.txt new file mode 100644 index 0000000..8007d1d --- /dev/null +++ b/third_party/licenses/dart/petitparser.txt @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2006-2026 Lukas Renggli. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third_party/licenses/dart/platform.txt b/third_party/licenses/dart/platform.txt new file mode 100644 index 0000000..389ce98 --- /dev/null +++ b/third_party/licenses/dart/platform.txt @@ -0,0 +1,26 @@ +Copyright 2017, the Dart project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/plugin_platform_interface.txt b/third_party/licenses/dart/plugin_platform_interface.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/plugin_platform_interface.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/posix.txt b/third_party/licenses/dart/posix.txt new file mode 100644 index 0000000..946e409 --- /dev/null +++ b/third_party/licenses/dart/posix.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Brett Sutton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/pub_semver.txt b/third_party/licenses/dart/pub_semver.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/pub_semver.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/record_use.txt b/third_party/licenses/dart/record_use.txt new file mode 100644 index 0000000..b03a788 --- /dev/null +++ b/third_party/licenses/dart/record_use.txt @@ -0,0 +1,27 @@ +Copyright 2024, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/safe_local_storage.txt b/third_party/licenses/dart/safe_local_storage.txt new file mode 100644 index 0000000..c727120 --- /dev/null +++ b/third_party/licenses/dart/safe_local_storage.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/share_plus.txt b/third_party/licenses/dart/share_plus.txt new file mode 100644 index 0000000..713fd93 --- /dev/null +++ b/third_party/licenses/dart/share_plus.txt @@ -0,0 +1,27 @@ +Copyright 2017, the Flutter project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/share_plus_platform_interface.txt b/third_party/licenses/dart/share_plus_platform_interface.txt new file mode 100644 index 0000000..7b99542 --- /dev/null +++ b/third_party/licenses/dart/share_plus_platform_interface.txt @@ -0,0 +1,27 @@ +Copyright 2017 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences.txt b/third_party/licenses/dart/shared_preferences.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/shared_preferences.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences_android.txt b/third_party/licenses/dart/shared_preferences_android.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/shared_preferences_android.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences_foundation.txt b/third_party/licenses/dart/shared_preferences_foundation.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/shared_preferences_foundation.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences_linux.txt b/third_party/licenses/dart/shared_preferences_linux.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/shared_preferences_linux.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences_platform_interface.txt b/third_party/licenses/dart/shared_preferences_platform_interface.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/shared_preferences_platform_interface.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences_web.txt b/third_party/licenses/dart/shared_preferences_web.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/shared_preferences_web.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/shared_preferences_windows.txt b/third_party/licenses/dart/shared_preferences_windows.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/shared_preferences_windows.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/sky_engine.txt b/third_party/licenses/dart/sky_engine.txt new file mode 100644 index 0000000..322a72e --- /dev/null +++ b/third_party/licenses/dart/sky_engine.txt @@ -0,0 +1,29844 @@ +vulkan-validation-layers + + + + +Copyright (c) 2017-2022 Advanced Micro Devices, Inc. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +fuchsia_sdk + + + + +Copyright © 2005-2014 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +Authors/contributors include: + +Alex Dowad +Alexander Monakov +Anthony G. Basile +Arvid Picciani +Bobby Bingham +Boris Brezillon +Brent Cook +Chris Spiegel +Clément Vasseur +Daniel Micay +Denys Vlasenko +Emil Renner Berthing +Felix Fietkau +Felix Janda +Gianluca Anzolin +Hauke Mehrtens +Hiltjo Posthuma +Isaac Dunham +Jaydeep Patil +Jens Gustedt +Jeremy Huntwork +Jo-Philipp Wich +Joakim Sindholt +John Spencer +Josiah Worcester +Justin Cormack +Khem Raj +Kylie McClain +Luca Barbato +Luka Perkov +M Farkas-Dyck (Strake) +Mahesh Bodapati +Michael Forney +Natanael Copa +Nicholas J. Kain +orc +Pascal Cuoq +Petr Hosek +Pierre Carrier +Rich Felker +Richard Pennington +Shiz +sin +Solar Designer +Stefan Kristiansson +Szabolcs Nagy +Timo Teräs +Trutz Behn +Valentin Ochs +William Haddon + +Portions of this software are derived from third-party works licensed +under terms compatible with the above MIT license: + +Much of the math library code (third_party/math/* and +third_party/complex/*, and third_party/include/libm.h) is +Copyright © 1993,2004 Sun Microsystems or +Copyright © 2003-2011 David Schultz or +Copyright © 2003-2009 Steven G. Kargl or +Copyright © 2003-2009 Bruce D. Evans or +Copyright © 2008 Stephen L. Moshier +and labelled as such in comments in the individual source files. All +have been licensed under extremely permissive terms. + +The smoothsort implementation (third_party/smoothsort/qsort.c) is +Copyright © 2011 Valentin Ochs and is licensed under an MIT-style +license. + +The x86_64 files in third_party/arch were written by Nicholas J. Kain +and is licensed under the standard MIT terms. + +All other files which have no copyright comments are original works +produced specifically for use as part of this library, written either +by Rich Felker, the main author of the library, or by one or more +contibutors listed above. Details on authorship of individual files +can be found in the git version control history of the project. The +omission of copyright and license comments in each file is in the +interest of source tree size. + +In addition, permission is hereby granted for all public header files +(include/* and arch/*/bits/*) and crt files intended to be linked into +applications (crt/*, ldso/dlstart.c, and arch/*/crt_arch.h) to omit +the copyright notice and permission notice otherwise required by the +license, and to use these files without any requirement of +attribution. These files include substantial contributions from: + +Bobby Bingham +John Spencer +Nicholas J. Kain +Rich Felker +Richard Pennington +Stefan Kristiansson +Szabolcs Nagy + +all of whom have explicitly granted such permission. + +This file previously contained text expressing a belief that most of +the files covered by the above exception were sufficiently trivial not +to be subject to copyright, resulting in confusion over whether it +negated the permissions granted in the license. In the spirit of +permissive licensing, and of not having licensing issues being an +obstacle to adoption, that text has been removed. + +-------------------------------------------------------------------------------- +harfbuzz + + + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE +-------------------------------------------------------------------------------- +vulkan_memory_allocator + + + +Copyright (C) 1997-2020 by Dimitri van Heesch + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright (C) 2000, 2001, 2002, 2003, 2006, 2010 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright (C) 2000-2004, 2006-2011, 2013, 2014 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright (C) 2001, 2002 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright (C) 2001, 2002, 2003, 2004 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright (C) 2001-2008, 2011, 2013, 2014 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +harfbuzz + + + +Copyright (C) 2012 Zilong Tan (eric.zltan@gmail.com) + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +libcxx +libcxxabi + + + +Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +include + + + +Copyright (c) 2013-2021 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +icu +json + + + +Copyright (c) 2013-2022 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +web_locale_keymap + + + +Copyright (c) 2015 - present Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +node_preamble + + + +Copyright (c) 2015 Michael Bullington + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +equatable + + + +Copyright (c) 2018 Felix Angelov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +inja + + + +Copyright (c) 2018-2021 Berscheid + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +vulkan_memory_allocator + + + +Copyright (c) 2021 - 2022 jothepro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +ceval + + + +Copyright (c) 2021 e_t + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +glslang + + + +Copyright (c) 2022 Google LLC +Copyright (c) 2022 Sascha Willems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +glslang + + + +Copyright (c) 2022 Sascha Willems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +glslang + + + +Copyright (c) 2023 NVIDIA CORPORATION. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +spring_animation + + + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright 2000, 2001, 2004 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright 2000-2001, 2002 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright 2000-2001, 2003 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright 2000-2010, 2012-2014 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + + + +Copyright 2003 by +Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +swiftshader + + + +Copyright © 2008-2011 Kristian Høgsberg +Copyright © 2010-2011 Intel Corporation +Copyright © 2012-2013 Collabora, Ltd. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice (including the +next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +angle +benchmark +boringssl +cpu_features +flatbuffers +gtest-parallel +skia +spirv-cross +spirv-tools +vulkan-headers +vulkan-tools +vulkan-utility-libraries +wuffs +wycheproof_testvectors +yapf + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +-------------------------------------------------------------------------------- +abseil-cpp + + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +-------------------------------------------------------------------------------- +include + + +Copyright (c) 2013-2019 Niels Lohmann . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +vulkan-tools + + +Copyright (c) 2015-2017 The Khronos Group Inc. +Copyright (c) 2015-2017 Valve Corporation +Copyright (c) 2015-2017 LunarG, Inc. +Copyright (c) 2015-2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2024 Google Inc. +Copyright (c) 2023-2024 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2025 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2025 Google Inc. +Copyright (c) 2015-2025 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2025 Google Inc. +Copyright (c) 2023-2025 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2016-2025 Google Inc. +Copyright (c) 2016-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glfw + + +Copyright (c) 2017 Sean Barrett +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +ffx_spd + + +Copyright (c) 2017-2019 Advanced Micro Devices, Inc. All rights reserved. +Copyright (c) <2014> +------- +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: +------- +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. +------- +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +ffx_spd + + +Copyright (c) 2017-2020 Advanced Micro Devices, Inc. All rights reserved. +------- +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: +------- +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. +------- +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2017-2024 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +vulkan_memory_allocator + + +Copyright (c) 2017-2025 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +vulkan-tools + + +Copyright (c) 2018 The Khronos Group Inc. +Copyright (c) 2018 Valve Corporation +Copyright (c) 2018 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan_memory_allocator + + +Copyright (c) 2018-2025 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2020-2025 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2021-2024 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2023-2025 Google Inc. +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2023-2025 The Khronos Group Inc. +Copyright (c) 2023-2025 Valve Corporation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2023-2025 The Khronos Group Inc. +Copyright (c) 2023-2025 Valve Corporation +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2023-2025 Valve Corporation +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2024-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2025 The Khronos Group Inc. +Copyright (c) 2025 Valve Corporation +Copyright (c) 2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright (c) 2025 Valve Corporation +Copyright (c) 2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + + +Copyright 2014, 2017 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + + +Copyright 2014-2024 Valve Software +Copyright 2015-2024 Google Inc. +Copyright 2019-2024 LunarG, Inc. +All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +flatbuffers + + +Copyright 2015 gRPC authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +flatbuffers + + +Copyright 2018 Dan Field. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +flutter + + +Copyright © 2015 Inigo Quilez +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright +notice and this permission notice shall be included in all copies or +substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", +WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +swiftshader + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +-------------------------------------------------------------------------------- +yapf_diff + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +-------------------------------------------------------------------------------- +flatbuffers +shaderc + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +-------------------------------------------------------------------------------- +perfetto + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright (c) 2017, The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-------------------------------------------------------------------------------- +perfetto + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright (c) 2020, The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-------------------------------------------------------------------------------- +freetype2 + + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (https://freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://freetype.org + + +--- end of FTL.TXT --- +-------------------------------------------------------------------------------- +etc_decoder + + * Copyright (c) 2020-2022 Hans-Kristian Arntzen + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * +-------------------------------------------------------------------------------- +harfbuzz + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Khaled Hosny + +-------------------------------------------------------------------------------- +expat + +! /bin/bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2017-2024 Sebastian Pipping +Copyright (c) 2017 Rolf Eike Beer +Copyright (c) 2019 Mohammed Khajapasha +Copyright (c) 2019 Manish, Kumar +Copyright (c) 2019 Philippe Antoine +Copyright (c) 2024 Dag-Erling Smørgrav +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2016-2023 Sebastian Pipping +Copyright (c) 2019 Philippe Antoine +Copyright (c) 2019-2025 Hanno Böck +Copyright (c) 2024 Alexander Bluhm +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2017 Sebastian Pipping +Copyright (c) 2019 Jeffrey Walton +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2017-2022 Sebastian Pipping +Copyright (c) 2018 Marco Maggi +Copyright (c) 2024 Dag-Erling Smørgrav +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2017-2024 Sebastian Pipping +Copyright (c) 2018 Marco Maggi +Copyright (c) 2019 Mohammed Khajapasha +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2019-2021 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2019-2022 Sebastian Pipping +Copyright (c) 2024 Dag-Erling Smørgrav +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2019-2024 Sebastian Pipping +Copyright (c) 2022 Rosen Penev +Copyright (c) 2024 Dag-Erling Smørgrav +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2020-2023 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2021-2022 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2021-2025 Sebastian Pipping +Copyright (c) 2024 Dag-Erling Smørgrav +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2024-2025 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env bash +Creates release tarball and detached GPG signature file for upload + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2018-2019 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +! /usr/bin/env python3 + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2019-2023 Sebastian Pipping +Copyright (c) 2021 Tim Bray +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +!/bin/sh +USAGE: get-version.sh path/to/expat.h + +This script will print Expat's version number on stdout. For example: + + $ ./conftools/get-version.sh ./lib/expat.h + 1.95.3 + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2002 Greg Stein +Copyright (c) 2017 Kerin Millar +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +!/usr/bin/env bash +Clean source directory after running the coverage script. + __ __ _ + ___\ \/ /_ __ __ _| |_ + / _ \\ /| '_ \ / _` | __| + | __// \| |_) | (_| | |_ + \___/_/\_\ .__/ \__,_|\__| + |_| XML parser + +Copyright (c) 2017 Rhodri James +Copyright (c) 2018 Marco Maggi +Copyright (c) 2019 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +libtess2 + +** SGI FREE SOFTWARE LICENSE B (Version 2.0, Sept. 18, 2008) +** Copyright (C) [dates of first publication] Silicon Graphics, Inc. +** All Rights Reserved. +** +** Permission is hereby granted, free of charge, to any person obtaining a copy +** of this software and associated documentation files (the "Software"), to deal +** in the Software without restriction, including without limitation the rights +** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +** of the Software, and to permit persons to whom the Software is furnished to do so, +** subject to the following conditions: +** +** The above copyright notice including the dates of first publication and either this +** permission notice or a reference to http://oss.sgi.com/projects/FreeB/ shall be +** included in all copies or substantial portions of the Software. +** +** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +** INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +** PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL SILICON GRAPHICS, INC. +** BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +** OR OTHER DEALINGS IN THE SOFTWARE. +** +** Except as contained in this notice, the name of Silicon Graphics, Inc. shall not +** be used in advertising or otherwise to promote the sale, use or other dealings in +** this Software without prior written authorization from Silicon Graphics, Inc. +-------------------------------------------------------------------------------- +libXNVCtrl + +/* + * Copyright (c) 2008 NVIDIA, Corporation + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +-------------------------------------------------------------------------------- +re2 + +// Copyright (c) 2009 The RE2 Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +skia + +// Copyright (c) 2011 Google Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +vulkan + +// Copyright (c) 2018 Google Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +expat +harfbuzz + +// Copyright (c) 2021 Google Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +angle + +// Copyright 2018 The ANGLE Project Authors. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc. +// Ltd., nor the names of their contributors may be used to endorse +// or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libcxx +libcxxabi +llvm_libc + +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. +-------------------------------------------------------------------------------- +icu +include +json + +@copyright Copyright (c) 2008-2009 Bjoern Hoehrmann +@sa http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ +-------------------------------------------------------------------------------- +etc1 + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty +percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled +object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object +form, made available under the License, as indicated by a copyright +notice that is included in or attached to the work (an example is +provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor +for inclusion in the Work by the copyright owner or by an individual or +Legal Entity authorized to submit on behalf of the copyright owner. For +the purposes of this definition, "submitted" means any form of electronic, +verbal, or written communication sent to the Licensor or its +representatives, including but not limited to communication on electronic +mailing lists, source code control systems, and issue tracking systems that +are managed by, or on behalf of, the Licensor for the purpose of discussing +and improving the Work, but excluding communication that is conspicuously +marked or otherwise designated in writing by the copyright owner as "Not +a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on +behalf of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in +this section) patent license to make, have made, use, offer to sell, sell, +import, and otherwise transfer the Work, where such license applies only to +those patent claims licensable by such Contributor that are necessarily +infringed by their Contribution(s) alone or by combination of their +Contribution(s) with the Work to which such Contribution(s) was submitted. +If You institute patent litigation against any entity (including a cross-claim +or counterclaim in a lawsuit) alleging that the Work or a Contribution +incorporated within the Work constitutes direct or contributory patent +infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and +in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that +You changed the files; and +You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices +from the Source form of the Work, excluding those notices that do not +pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable +copy of the attribution notices contained within such NOTICE file, excluding +those notices that do not pertain to any part of the Derivative Works, in +at least one of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or documentation, if +provided along with the Derivative Works; or, within a display generated by +the Derivative Works, if and wherever such third-party notices normally +appear. The contents of the NOTICE file are for informational purposes +only and do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside or as +an addendum to the NOTICE text from the Work, provided that such additional +attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a +whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein +shall supersede or modify the terms of any separate license agreement you +may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as +required for reasonable and customary use in describing the origin of the +Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to +in writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +ANY KIND, either express or implied, including, without limitation, any +warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining +the appropriateness of using or redistributing the Work and assume any risks +associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to +in writing, shall any Contributor be liable to You for damages, including +any direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or inability +to use the Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all other +commercial damages or losses), even if such Contributor has been advised +of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the +Work or Derivative Works thereof, You may choose to offer, and charge a +fee for, acceptance of support, warranty, indemnity, or other liability +obligations and/or rights consistent with this License. However, in accepting +such obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any +liability incurred by, or claims asserted against, such Contributor by +reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as +defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner +that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that +control, are controlled by, or are under common control with that entity. For the +purposes of this definition, "control" means (i) the power, direct or indirect, to +cause the direction or management of such entity, whether by contract or otherwise, +or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or +(iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions +granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not +limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included in or +attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based +on (or derived from) the Work and for which the editorial revisions, annotations, +elaborations, or other modifications represent, as a whole, an original work of +authorship. For the purposes of this License, Derivative Works shall not include works +that remain separable from, or merely link (or bind by name) to the interfaces of, the +Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the +Work and any modifications or additions to that Work or Derivative Works thereof, that +is intentionally submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. +For the purposes of this definition, "submitted" means any form of electronic, verbal, +or written communication sent to the Licensor or its representatives, including but not +limited to communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose +of discussing and improving the Work, but excluding communication that is conspicuously +marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a +Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each +Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each +Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable (except as stated in this section) patent license to make, +have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such +license applies only to those patent claims licensable by such Contributor that are +necessarily infringed by their Contribution(s) alone or by combination of their +Contribution(s) with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a cross-claim or counterclaim +in a lawsuit) alleging that the Work or a Contribution incorporated within the Work +constitutes direct or contributory patent infringement, then any patent licenses granted +to You under this License for that Work shall terminate as of the date such litigation +is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative +Works thereof in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this +License; and +You must cause any modified files to carry prominent notices stating that You changed +the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all +copyright, patent, trademark, and attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the attribution +notices contained within such NOTICE file, excluding those notices that do not pertain +to any part of the Derivative Works, in at least one of the following places: within a +NOTICE text file distributed as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, within a display +generated by the Derivative Works, if and wherever such third-party notices normally +appear. The contents of the NOTICE file are for informational purposes only and do not +modify the License. You may add Your own attribution notices within Derivative Works +that You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as modifying +the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution +intentionally submitted for inclusion in the Work by You to the Licensor shall be under +the terms and conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of any +separate license agreement you may have executed with Licensor regarding such +Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and reproducing the +content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, +Licensor provides the Work (and each Contributor provides its Contributions) on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, + including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, + MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for + determining the appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort +(including negligence), contract, or otherwise, unless required by applicable law (such +as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor +be liable to You for damages, including any direct, indirect, special, incidental, or +consequential damages of any character arising as a result of this License or out of the +use or inability to use the Work (including but not limited to damages for loss of +goodwill, work stoppage, computer failure or malfunction, or any and all other +commercial damages or losses), even if such Contributor has been advised of the +possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of +support, warranty, indemnity, or other liability obligations and/or rights consistent +with this License. However, in accepting such obligations, You may act only on Your own +behalf and on Your sole responsibility, not on behalf of any other Contributor, and only +if You agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your accepting +any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: HOW TO APPLY THE APACHE LICENSE TO YOUR WORK +To apply the Apache License to your work, attach the following boilerplate notice, with +the fields enclosed by brackets "[]" replaced with your own identifying information. +(Don't include the brackets!) The text should be enclosed in the appropriate comment +syntax for the file format. We also recommend that a file or class name and description +of purpose be included on the same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +-------------------------------------------------------------------------------- +spirv-cross + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, +AND DISTRIBUTION + + 1. Definitions. + + + +"License" shall mean the terms and conditions for use, reproduction, and distribution +as defined by Sections 1 through 9 of this document. + + + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + + + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct +or indirect, to cause the direction or management of such entity, whether +by contract or otherwise, or (ii) ownership of fifty percent (50%) or more +of the outstanding shares, or (iii) beneficial ownership of such entity. + + + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions +granted by this License. + + + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + + + +"Object" form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + + + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that +is included in or attached to the work (an example is provided in the Appendix +below). + + + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative +Works shall not include works that remain separable from, or merely link (or +bind by name) to the interfaces of, the Work and Derivative Works thereof. + + + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative +Works thereof, that is intentionally submitted to Licensor for inclusion in +the Work by the copyright owner or by an individual or Legal Entity authorized +to submit on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication +sent to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor +for the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + + + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently incorporated +within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable copyright license to reproduce, prepare +Derivative Works of, publicly display, publicly perform, sublicense, and distribute +the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, +each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) patent +license to make, have made, use, offer to sell, sell, import, and otherwise +transfer the Work, where such license applies only to those patent claims +licensable by such Contributor that are necessarily infringed by their Contribution(s) +alone or by combination of their Contribution(s) with the Work to which such +Contribution(s) was submitted. If You institute patent litigation against +any entity (including a cross-claim or counterclaim in a lawsuit) alleging +that the Work or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses granted to You +under this License for that Work shall terminate as of the date such litigation +is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and +in Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source +form of the Work, excluding those notices that do not pertain to any part +of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable copy +of the attribution notices contained within such NOTICE file, excluding those +notices that do not pertain to any part of the Derivative Works, in at least +one of the following places: within a NOTICE text file distributed as part +of the Derivative Works; within the Source form or documentation, if provided +along with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works +that You distribute, alongside or as an addendum to the NOTICE text from the +Work, provided that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, +or distribution of Your modifications, or for any such Derivative Works as +a whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without +any additional terms or conditions. Notwithstanding the above, nothing herein +shall supersede or modify the terms of any separate license agreement you +may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to +in writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any warranties +or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR +A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness +of using or redistributing the Work and assume any risks associated with Your +exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether +in tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to +in writing, shall any Contributor be liable to You for damages, including +any direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or inability +to use the Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all other commercial +damages or losses), even if such Contributor has been advised of the possibility +of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work +or Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such obligations, +You may act only on Your own behalf and on Your sole responsibility, not on +behalf of any other Contributor, and only if You agree to indemnify, defend, +and hold each Contributor harmless for any liability incurred by, or claims +asserted against, such Contributor by reason of your accepting any such warranty +or additional liability. END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own identifying +information. (Don't include the brackets!) The text should be enclosed in +the appropriate comment syntax for the file format. We also recommend that +a file or class name and description of purpose be included on the same "printed +page" as the copyright notice for easier identification within third-party +archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); + +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software + +distributed under the License is distributed on an "AS IS" BASIS, + +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and + +limitations under the License. + +-------------------------------------------------------------------------------- +fuchsia_sdk + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +--- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. +-------------------------------------------------------------------------------- +json + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +-------------------------------------------------------------------------------- +glfw + +Copyright (C) 1997-2013 Sam Lantinga + +This software is provided 'as-is', without any express or implied warranty. +In no event will the authors be held liable for any damages arising from the +use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +zlib + +Copyright (C) 1998-2005 Gilles Vollant +-------------------------------------------------------------------------------- +zlib + +Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html ) + +Modifications for Zip64 support +Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com ) + +For more info read MiniZip_info.txt + +--------------------------------------------------------------------------- + +Condition of use and distribution are the same than zlib : + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +zlib + +Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html ) + +Modifications of Unzip for Zip64 +Copyright (C) 2007-2008 Even Rouault + +Modifications for Zip64 support on both zip and unzip +Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com ) + +For more info read MiniZip_info.txt + +--------------------------------------------------------------------------------- + +Condition of use and distribution are the same than zlib : + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2000-2012, International Business Machines Corporation and others. +All Rights Reserved. +-------------------------------------------------------------------------------- +swiftshader + +Copyright (C) 2001-2006 Bart Massey, Jamey Sharp, and Josh Triplett. +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2004 3Dlabs Inc. Ltd. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2015-2018 Google, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2015-2019 Google, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2018-2020 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2017, 2022-2024 Arm Limited. +Copyright (C) 2015-2018 Google, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2024 Valve Corporation. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2013 LunarG, Inc. +Copyright (C) 2017, 2022-2024 Arm Limited. +Copyright (C) 2015-2020 Google, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2015 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. +Copyright (C) 2017, 2019 ARM Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2024 Ravi Prakash Singh. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2015 LunarG, Inc. +Copyright (C) 2015-2020 Google, Inc. +Copyright (C) 2017 ARM Limited. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2016 LunarG, Inc. +Copyright (C) 2015-2016 Google, Inc. +Copyright (C) 2017 ARM Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2016 LunarG, Inc. +Copyright (C) 2015-2020 Google, Inc. +Copyright (C) 2017, 2022-2024 Arm Limited. +Modifications Copyright (C) 2020-2021 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2012-2016 LunarG, Inc. +Copyright (C) 2017, 2022-2024 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2015-2018 Google, Inc. +Copyright (c) 2023, Mobica Limited + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2020 Google, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013 LunarG, Inc. +Copyright (c) 2002-2010 The ANGLE Project Authors. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013-2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013-2016 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013-2016 LunarG, Inc. +Copyright (C) 2015-2020 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2013-2016 LunarG, Inc. +Copyright (C) 2016-2020 Google, Inc. +Modifications Copyright(C) 2021 Advanced Micro Devices, Inc.All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2016 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2016 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2015-2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2002-2005 3Dlabs Inc. Ltd. +Copyright (C) 2017 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2002-2013, International Business Machines Corporation +and others. All Rights Reserved. +-------------------------------------------------------------------------------- +swiftshader + +Copyright (C) 2008 The Android Open Source Project +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2009-2011, Nokia Corporation and/or its subsidiary(-ies). +All Rights Reserved. +Author: Siarhei Siamashka +Copyright (C) 2013-2014, Linaro Limited. All Rights Reserved. +Author: Ragesh Radhakrishnan +Copyright (C) 2014-2016, D. R. Commander. All Rights Reserved. +Copyright (C) 2015-2016, Matthieu Darbois. All Rights Reserved. +Copyright (C) 2016, Siarhei Siamashka. All Rights Reserved. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2009-2011, Nokia Corporation and/or its subsidiary(-ies). +All Rights Reserved. +Author: Siarhei Siamashka +Copyright (C) 2014, Siarhei Siamashka. All Rights Reserved. +Copyright (C) 2014, Linaro Limited. All Rights Reserved. +Copyright (C) 2015, D. R. Commander. All Rights Reserved. +Copyright (C) 2015-2016, Matthieu Darbois. All Rights Reserved. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +cpu_features + +Copyright (C) 2010 The Android Open Source Project +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +-------------------------------------------------------------------------------- +harfbuzz + +Copyright (C) 2011 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +include + +Copyright (C) 2011 Nick Bruun +Copyright (C) 2013 Vlad Lazarenko +Copyright (C) 2014 Nicolas Pauss +-------------------------------------------------------------------------------- +harfbuzz + +Copyright (C) 2012 Grigori Goronzy + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2012 LunarG, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of LunarG Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +xxhash + +Copyright (C) 2012-2016, Yann Collet + + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +xxhash + +Copyright (C) 2012-2016, Yann Collet. + +BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (C) 2012-2021 Yann Collet + +BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php) + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2013 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2013 LunarG, Inc. +Copyright (C) 2017 ARM Limited. +Copyright (C) 2015-2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2013, MIPS Technologies, Inc., California. +All Rights Reserved. +Authors: Teodora Novkovic (teodora.novkovic@imgtec.com) + Darko Laus (darko.laus@imgtec.com) +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2013-2014, MIPS Technologies, Inc., California. +All Rights Reserved. +Authors: Teodora Novkovic (teodora.novkovic@imgtec.com) + Darko Laus (darko.laus@imgtec.com) +Copyright (C) 2015, D. R. Commander. All Rights Reserved. +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2013-2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +skia + +Copyright (C) 2014 Google Inc. All rights reserved. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2014, D. R. Commander. All Rights Reserved. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2015 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2015 LunarG, Inc. +Copyright (C) 2015-2018 Google, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2015 LunarG, Inc. +Copyright (C) 2015-2020 Google, Inc. +Copyright (C) 2017 ARM Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2015 LunarG, Inc. +Copyright (C) 2022-2025 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2014-2015, D. R. Commander. All Rights Reserved. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2014-2015, D. R. Commander. All Rights Reserved. +Copyright (C) 2014, Jay Foad. All Rights Reserved. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2016 LunarG, Inc. +Copyright (C) 2015-2020 Google, Inc. +Copyright (C) 2017, 2022-2025 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2016 LunarG, Inc. +Copyright (C) 2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2014-2016 LunarG, Inc. +Copyright (C) 2018-2020 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C) 2015, D. R. Commander. All Rights Reserved. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2015-2016 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2015-2018 Google, Inc. +Copyright (C) 2017 ARM Limited. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (C) 2015-2021 Valve Corporation +Copyright (C) 2015-2021 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google, Inc., nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. +Copyright (C) 2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. +Copyright (C) 2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google, Inc., nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. +Copyright (C) 2019, 2022-2024 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 Google, Inc. +Copyright (C) 2022-2024 Arm Limited. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google, Inc., nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2015, International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2005, International Business Machines Corporation and others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2016 International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +#################################################################### +Copyright (c) 2009, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +#################################################################### +Copyright (c) 2015, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +* +* Copyright (C) 2004-2006, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2002, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2003, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2005, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2006, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2007, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2009, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 1995-2013, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 2001-2003, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 2001-2005, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 2009-2012, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +*************************************************************************** +* +* Copyright (C) 2014, International Business Machines +* Corporation and others. All Rights Reserved. +* +*************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +***************************************************************************** + + Copyright (C) 2002-2015, International Business Machines Corporation and others. + All Rights Reserved. + +***************************************************************************** + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +******************************************************************************* +* +* Copyright (C) 1995-2005, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +******************************************************************************* +* +* Copyright (C) 1995-2010, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +******************************************************************************* +* +* Copyright (C) 1997-2000, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +******************************************************************************* +* +* Copyright (C) 1997-2003, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +******************************************************************************* +* +* Copyright (C) 1997-2010, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +******************************************************************************* +* +* Copyright (C) 2010, International Business Machines +* Corporation and others. All Rights Reserved. +* +******************************************************************************* + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 1999-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2009-2010 IBM Corporation and Others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2010-2014, International Business Machines Corporation and others. +All Rights Reserved. +-------------------------------------------------------------------------------- +icu + +Copyright (C) 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2002-2016 International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016-2017 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016-2017 Google, Inc. +Copyright (C) 2020 The Khronos Group Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016-2017 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016-2018 Google, Inc. +Copyright (C) 2016 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2016-2018 Google, Inc. +Copyright (C) 2016 LunarG, Inc. +Copyright (C) 2023 Mobica Limited. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google, Inc., nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +zlib + +Copyright (C) 2017 ARM, Inc. +Copyright 2017 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the Chromium source repository LICENSE file. +-------------------------------------------------------------------------------- +shaderc + +Copyright (C) 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2017 LunarG, Inc. +Copyright (C) 2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2017 LunarG, Inc. +Copyright (C) 2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google, Inc., nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +perfetto + +Copyright (C) 2017 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS +IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +express or implied. See the License for the specific language +governing permissions and limitations under the License. +-------------------------------------------------------------------------------- +perfetto + +Copyright (C) 2017 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2017-2018 Google, Inc. +Copyright (C) 2017 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +shaderc + +Copyright (C) 2017-2022 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2018 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +perfetto + +Copyright (C) 2018 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS +IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +express or implied. See the License for the specific language +governing permissions and limitations under the License. +-------------------------------------------------------------------------------- +angle +dart +perfetto + +Copyright (C) 2018 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright (C) 2018 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2018 The Khronos Group Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (C) 2019 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2019 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart +perfetto + +Copyright (C) 2019 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +shaderc + +Copyright (C) 2020 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2020 Google, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart +perfetto +swiftshader + +Copyright (C) 2020 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +shaderc + +Copyright (C) 2020-2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart +perfetto + +Copyright (C) 2021 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart +perfetto + +Copyright (C) 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart +perfetto + +Copyright (C) 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2023 LunarG, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart +perfetto + +Copyright (C) 2023 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2024 The Khronos Group Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2025 Jan Kelemen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2025 NVIDIA Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +glslang + +Copyright (C) 2025 The Khronos Group Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +harfbuzz + +Copyright (C) 2026 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C)2009-2014 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C)2009-2015 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C)2009-2016 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C)2011 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C)2011, 2015 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +Copyright (C)2011-2016 D. R. Commander. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +expat + +Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper +Copyright (c) 2001-2025 Expat maintainers + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2002, NVIDIA Corporation. + +NVIDIA Corporation("NVIDIA") supplies this software to you in +consideration of your agreement to the following terms, and your use, +installation, modification or redistribution of this NVIDIA software +constitutes acceptance of these terms. If you do not agree with these +terms, please do not use, install, modify or redistribute this NVIDIA +software. + +In consideration of your agreement to abide by the following terms, and +subject to these terms, NVIDIA grants you a personal, non-exclusive +license, under NVIDIA's copyrights in this original NVIDIA software (the +"NVIDIA Software"), to use, reproduce, modify and redistribute the +NVIDIA Software, with or without modifications, in source and/or binary +forms; provided that if you redistribute the NVIDIA Software, you must +retain the copyright notice of NVIDIA, this notice and the following +text and disclaimers in all such redistributions of the NVIDIA Software. +Neither the name, trademarks, service marks nor logos of NVIDIA +Corporation may be used to endorse or promote products derived from the +NVIDIA Software without specific prior written permission from NVIDIA. +Except as expressly stated in this notice, no other rights or licenses +express or implied, are granted by NVIDIA herein, including but not +limited to any patent rights that may be infringed by your derivative +works or by other works in which the NVIDIA Software may be +incorporated. No hardware is licensed hereunder. + +THE NVIDIA SOFTWARE IS BEING PROVIDED ON AN "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, +INCLUDING WITHOUT LIMITATION, WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR +ITS USE AND OPERATION EITHER ALONE OR IN COMBINATION WITH OTHER +PRODUCTS. + +IN NO EVENT SHALL NVIDIA BE LIABLE FOR ANY SPECIAL, INDIRECT, +INCIDENTAL, EXEMPLARY, CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, LOST PROFITS; PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) OR ARISING IN ANY WAY +OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION OF THE +NVIDIA SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY OF CONTRACT, +TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY OR OTHERWISE, EVEN IF +NVIDIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2002-2006 Marcus Geelnard + +Copyright (c) 2006-2019 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2016 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2017 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2018 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2019 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2019 Camilla Löwy +Copyright (c) 2012 Torsten Walluhn + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +icu + +Copyright (c) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2003-2005 Tom Wu +Copyright (c) 2012 Adam Singer (adam@solvr.io) +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, +EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY +WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, +INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF +THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +In addition, the following condition applies: + +All redistributions must retain an intact copy of this copyright notice +and disclaimer. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2006-2017 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2006-2018 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +icu + +Copyright (c) 2007-2012, International Business Machines +Corporation and others. All Rights Reserved. +-------------------------------------------------------------------------------- +libXNVCtrl + +Copyright (c) 2008 NVIDIA, Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2008-2017 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle +glfw + +Copyright (c) 2008-2018 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2008-2020 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2008-2021 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2008-2023 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +include + +Copyright (c) 2009 Florian Loitsch. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- +libtess2 + +Copyright (c) 2009 Mikko Mononen memon@inside.org + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +accessibility + +Copyright (c) 2009 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +brotli + +Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2009-2016 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2009-2019 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2009-2019 Camilla Löwy +Copyright (c) 2012 Torsten Walluhn + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2009-2021 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +libXNVCtrl + +Copyright (c) 2010 NVIDIA, Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2010 Olivier Delannoy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +accessibility + +Copyright (c) 2010 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2010, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +include + +Copyright (c) 2011 - Nick Bruun. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. If you meet (any of) the author(s), you're encouraged to buy them a beer, + a drink or whatever is suited to the situation, given that you like the + software. +4. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2011 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +accessibility +angle +dart + +Copyright (c) 2011 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2012 Marcus Geelnard + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. +-------------------------------------------------------------------------------- +accessibility +dart +flutter +icu +skia + +Copyright (c) 2012 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility +angle +dart +skia + +Copyright (c) 2013 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2013 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +khronos + +Copyright (c) 2013-2014 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2013-2017 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2013-2018 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2014 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2014 Jonas Ådahl + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +accessibility +skia + +Copyright (c) 2014 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +glslang +skia +spirv-tools + +Copyright (c) 2014-2016 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2014-2017 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +spirv-cross + +Copyright (c) 2014-2020 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +spirv-cross + +Copyright (c) 2014-2020 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2014-2020 The Khronos Group Inc. +Copyright (C) 2022-2024 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +dart +skia + +Copyright (c) 2015 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2015-2016 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang +spirv-tools + +Copyright (c) 2015-2016 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2015-2016 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS +KHRONOS STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS +SPECIFICATIONS AND HEADER INFORMATION ARE LOCATED AT + https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2015-2016 The Khronos Group Inc. +Copyright (c) 2015-2016 Valve Corporation +Copyright (c) 2015-2016 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2016 The Khronos Group Inc. +Copyright (c) 2015-2023 Valve Corporation +Copyright (c) 2015-2023 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2015-2016 The Khronos Group Inc. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2016, 2020-2025 The Khronos Group Inc. +Copyright (c) 2015-2016, 2020-2025 Valve Corporation +Copyright (c) 2015-2016, 2020-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-utility-libraries + +Copyright (c) 2015-2017, 2019-2024 The Khronos Group Inc. +Copyright (c) 2015-2017, 2019-2024 Valve Corporation +Copyright (c) 2015-2017, 2019-2024 LunarG, Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-utility-libraries + +Copyright (c) 2015-2017, 2019-2024 The Khronos Group Inc. +Copyright (c) 2015-2017, 2019-2024 Valve Corporation +Copyright (c) 2015-2017, 2019-2024 LunarG, Inc. +Modifications Copyright (C) 2022 RasterGrid Kft. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2017, 2019-2025 The Khronos Group Inc. +Copyright (c) 2015-2017, 2019-2025 Valve Corporation +Copyright (c) 2015-2017, 2019-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2017, 2019-2025 The Khronos Group Inc. +Copyright (c) 2015-2017, 2019-2025 Valve Corporation +Copyright (c) 2015-2017, 2019-2025 LunarG, Inc. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2015-2018, 2023 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2015-2019 The Khronos Group Inc. +Copyright (c) 2015-2019 Valve Corporation +Copyright (c) 2015-2019 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2015-2019 The Khronos Group Inc. +Copyright (c) 2015-2019 Valve Corporation +Copyright (c) 2015-2019 LunarG, Inc. +Copyright (c) 2025 The Fuchsia Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2020 The Khronos Group Inc. +Copyright (c) 2015-2023 Valve Corporation +Copyright (c) 2015-2023 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2015-2020 The Khronos Group Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2015-2021 The Khronos Group Inc. +Copyright (c) 2015-2021 Valve Corporation +Copyright (c) 2015-2021 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2015-2021 The Khronos Group Inc. +Copyright (c) 2015-2021 Valve Corporation +Copyright (c) 2015-2021 LunarG, Inc. +Copyright (c) 2015-2021 Google, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2015-2021 The Khronos Group Inc. +Copyright (c) 2015-2021 Valve Corporation +Copyright (c) 2015-2021 LunarG, Inc. +Copyright (c) 2023-2024 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2015-2022 The Khronos Group Inc. +Modifications Copyright (C) 2020-2024 Advanced Micro Devices, Inc. All +rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2015-2023 The Khronos Group Inc. +Copyright (c) 2015-2023 Valve Corporation +Copyright (c) 2015-2023 LunarG, Inc. +Copyright (C) 2015-2016 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (C) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (c) 2015-2024 Valve Corporation +Copyright (c) 2015-2024 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (c) 2015-2024 Valve Corporation +Copyright (c) 2015-2024 LunarG, Inc. +Copyright (C) 2015-2023 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (c) 2015-2024 Valve Corporation +Copyright (c) 2015-2024 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-utility-libraries + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (c) 2015-2024 Valve Corporation +Copyright (c) 2015-2024 LunarG, Inc. +Copyright (c) 2015-2024 Google Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (c) 2015-2024 Valve Corporation +Copyright (c) 2015-2024 LunarG, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2024 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Modifications Copyright (C) 2020-2024 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2015-2025 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2023 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2023 Google Inc. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Copyright (c) 2025 Arm Limited. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2024 Google Inc. +Modifications Copyright (C) 2020-2024 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (C) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (C) 2025 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (C) 2025 Arm Limited. +Modifications Copyright (C) 2020-2025 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022-2025 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (c) 2025 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (c) 2025 Arm Limited. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (c) 2025 Arm Limited. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Copyright (c) 2025 Arm Limited. +Modifications Copyright (C) 2020-2024 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. +Copyright (c) 2025 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (C) 2015-2025 Google Inc. +Modifications Copyright (C) 2020-2024 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022-2024 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2024 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-utility-libraries + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2025 Google Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2015-2025 The Khronos Group Inc. +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Modifications Copyright (C) 2020-2022 Advanced Micro Devices, Inc. All rights reserved. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2015-2025 Valve Corporation +Copyright (c) 2015-2025 LunarG, Inc. +Copyright (c) 2015-2017, 2019, 2021 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2016 Advanced Micro Devices, Inc. All rights reserved. +Copyright (C) 2015-2021 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2016 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2016 Google Inc. +Copyright (c) 2016-2017 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2016 Google Inc. +Copyright (c) 2016-2019 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2016 Google Inc. +Copyright (c) 2025 Arm Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2016 Google Inc. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2016 Google, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2016 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart +flutter + +Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2016-2017 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 Google Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 Google Inc. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 Pierre Moreau + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2017 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2017 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 The Khronos Group Inc. +Copyright (c) 2017 Valve Corporation +Copyright (c) 2017 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 The Khronos Group Inc. +Copyright (c) 2017 Valve Corporation +Copyright (c) 2017 LunarG Inc. +Copyright (c) 2018 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 The Khronos Group Inc. +Copyright (c) 2017 Valve Corporation +Copyright (c) 2017 LunarG Inc. +Copyright (c) 2018-2021 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 The Khronos Group Inc. +Copyright (c) 2017 Valve Corporation +Copyright (c) 2017 LunarG Inc. +Copyright (c) 2019 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2017 The Khronos Group Inc. +Copyright (c) 2017 Valve Corporation +Copyright (c) 2017 LunarG Inc. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +vulkan_memory_allocator + +Copyright (c) 2017-2025 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC. +Copyright (c) 2019 NVIDIA Corporation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC. +Modifications Copyright (C) 2020-2024 Advanced Micro Devices, Inc. All +rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 Google LLC. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. +Copyright (c) 2024 NVIDIA Corporation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +icu +skia + +Copyright (c) 2018 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2018 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2018 The Khronos Group Inc. +Copyright (c) 2018 Valve Corporation +Copyright (c) 2018 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2018 The Khronos Group Inc. +Copyright (c) 2018 Valve Corporation +Copyright (c) 2018 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +volk + +Copyright (c) 2018-2019 Arseny Kapoulkine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +vulkan-headers + +Copyright (c) 2018-2019 Collabora, Ltd. +Copyright 2013-2025 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2018-2020 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2018-2020 The Khronos Group Inc. +Copyright (c) 2018-2020 Valve Corporation +Copyright (c) 2018-2020 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2018-2021 The Khronos Group Inc. +Copyright (c) 2018-2023 Valve Corporation +Copyright (c) 2018-2023 LunarG, Inc. +Copyright (C) 2018-2021 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +volk + +Copyright (c) 2018-2024 Arseny Kapoulkine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2018-2024 The Khronos Group Inc. +Copyright (c) 2018-2024 Valve Corporation +Copyright (c) 2018-2024 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2018-2025 The Khronos Group Inc. +Copyright (c) 2018-2025 Valve Corporation +Copyright (c) 2018-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2018-2025 The Khronos Group Inc. +Copyright (c) 2018-2025 Valve Corporation +Copyright (c) 2018-2025 LunarG, Inc. +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2019 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2019 Google LLC +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights +reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2019 Google LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +perfetto + +Copyright (c) 2019 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); you +may not use this file except in compliance with the License. You may +obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2019 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2019 The Khronos Group Inc. +Copyright (c) 2019 Valve Corporation +Copyright (c) 2019 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2019 The Khronos Group Inc. +Copyright (c) 2019 Valve Corporation +Copyright (c) 2019 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2019 The Khronos Group Inc. +Copyright (c) 2019 Valve Corporation +Copyright (c) 2019 LunarG, Inc. +Copyright (c) 2023 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2019 Valve Corporation +Copyright (c) 2019 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019, 2021, 2023-2025 The Khronos Group Inc. +Copyright (c) 2019, 2021, 2023-2025 Valve Corporation +Copyright (c) 2019, 2021, 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2019, Viktor Latypov +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2019-2020 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2019-2022 The Khronos Group Inc. +Copyright (c) 2019-2022 Valve Corporation +Copyright (c) 2019-2022 LunarG, Inc. +Copyright (c) 2023-2024 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019-2024 The Khronos Group Inc. +Copyright (c) 2019-2024 Valve Corporation +Copyright (c) 2019-2024 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-utility-libraries + +Copyright (c) 2019-2024 The Khronos Group Inc. +Copyright (c) 2019-2024 Valve Corporation +Copyright (c) 2019-2024 LunarG, Inc. +Copyright (C) 2019-2024 Google Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019-2025 The Khronos Group Inc. +Copyright (c) 2019-2025 Valve Corporation +Copyright (c) 2019-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019-2025 The Khronos Group Inc. +Copyright (c) 2019-2025 Valve Corporation +Copyright (c) 2019-2025 LunarG, Inc. +Copyright (C) 2019-2025 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019-2025 The Khronos Group Inc. +Copyright (c) 2019-2025 Valve Corporation +Copyright (c) 2019-2025 LunarG, Inc. +Copyright (C) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019-2025 The Khronos Group Inc. +Copyright (c) 2019-2025 Valve Corporation +Copyright (c) 2019-2025 LunarG, Inc. +Modifications Copyright (C) 2022 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2019-2025 Valve Corporation +Copyright (c) 2019-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +ffx_spd + +Copyright (c) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020 André Perez Maselco + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2020 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2020 Google LLC All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020 Stefano Milizia + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020 Stefano Milizia +Copyright (c) 2020 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2020 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2020 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS +KHRONOS STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS +SPECIFICATIONS AND HEADER INFORMATION ARE LOCATED AT + https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2020 The Khronos Group Inc. +Copyright (c) 2020 Valve Corporation +Copyright (c) 2020 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020 Vasyl Teliman + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2020, Travis Fort +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2020-2021 Valve Corporation +Copyright (c) 2020-2021 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2020-2022 Google LLC +Copyright (c) 2022 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +etc_decoder + +Copyright (c) 2020-2022 Hans-Kristian Arntzen + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2020-2022 Valve Corporation +Copyright (c) 2020-2022 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2020-2025 The Khronos Group Inc. +Copyright (c) 2020-2025 Valve Corporation +Copyright (c) 2020-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2020-2025 The Khronos Group Inc. +Copyright (c) 2020-2025 Valve Corporation +Copyright (c) 2020-2025 LunarG, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2020-2025 Valve Corporation +Copyright (c) 2020-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2021 Alastair F. Donaldson + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2021 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glslang +spirv-tools + +Copyright (c) 2021 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2021 Google LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2021 Mostafa Ashraf + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2021 Shiyu Liu + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2021 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2021 The Khronos Group Inc. +Copyright (c) 2021 Valve Corporation +Copyright (c) 2021 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2021 ZHOU He + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +libwebp + +Copyright (c) 2021, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. +Copyright (C) 2021-2022 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. +Copyright (C) 2021-2025 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. +Copyright (C) 2021-2025 Google Inc. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2021-2025 Valve Corporation +Copyright (c) 2021-2025 LunarG, Inc. +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2021-2025 The Khronos Group Inc. +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2022 Advanced Micro Devices, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) 2022 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright (c) 2022 Google LLC All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2022 Google LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2022 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2022 The Khronos Group Inc. +Copyright (c) 2022 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +test_shaders + +Copyright (c) 2022 by Selman Ay (https://codepen.io/selmanays/pen/yLVmEqY) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2022, 2025 ARM Limited + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and/or associated documentation files (the "Materials"), +to deal in the Materials without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Materials, and to permit persons to whom the +Materials are furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Materials. + +MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +IN THE MATERIALS. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2022-2023 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2022-2024 The Khronos Group Inc. +Copyright (c) 2022-2024 RasterGrid Kft. +Modifications Copyright (C) 2024 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2022-2025 The Khronos Group Inc. +Copyright (c) 2022-2025 Valve Corporation +Copyright (c) 2022-2025 LunarG, Inc. +Modifications Copyright (C) 2020 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools + +Copyright (c) 2022-2025 Valve Corporation +Copyright (c) 2022-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2023 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2023 Google LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2023 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright (c) 2023 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023 The Khronos Group Inc. +Copyright (c) 2023 Valve Corporation +Copyright (c) 2023 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +dart +perfetto + +Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2024 LunarG, Inc. +Copyright (c) 2023-2024 Valve Corporation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2024 Nintendo +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2023-2025 Arm Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2025 LunarG, Inc. +Copyright (c) 2023-2025 Valve Corporation +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2025 Nintendo +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2025 The Khronos Group Inc. +Copyright (c) 2023-2025 Valve Corporation +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2025 The Khronos Group Inc. +Copyright (c) 2023-2025 Valve Corporation +Copyright (c) 2023-2025 LunarG, Inc. +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2023-2025 Valve Corporation +Copyright (c) 2023-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2024 Epic Games, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2024 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2024 NVIDIA Corporation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2024 The Khronos Group Inc. +Copyright (c) 2024 LunarG, Inc. +Copyright (c) 2024 Advanced Micro Devices, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools +vulkan-validation-layers + +Copyright (c) 2024 The Khronos Group Inc. +Copyright (c) 2024 Valve Corporation +Copyright (c) 2024 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2024 The Khronos Group Inc. +Copyright (c) 2025 Valve Corporation +Copyright (c) 2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2024 Valve Corporation +Copyright (c) 2024 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2024-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2024-2025 The Khronos Group Inc. +Copyright (c) 2024-2025 Valve Corporation +Copyright (c) 2024-2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2024-2025 The Khronos Group Inc. +Copyright (c) 2024-2025 Valve Corporation +Copyright (c) 2024-2025 LunarG, Inc. +Copyright (c) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2025 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2025 LunarG Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools + +Copyright (c) 2025 The Fuchsia Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +glslang + +Copyright (c) 2025 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2025 The Khronos Group Inc. +Copyright (C) 2025 Arm Limited. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2025 The Khronos Group Inc. +Copyright (c) 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-tools +vulkan-validation-layers + +Copyright (c) 2025 The Khronos Group Inc. +Copyright (c) 2025 Valve Corporation +Copyright (c) 2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright (c) 2025 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright (c) 2025 Valve Corporation +Copyright (c) 2025 LunarG, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +ai +dart + +Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +perfetto + +Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +ai +dart + +Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +glfw + +Copyright (c) Marcus Geelnard +Copyright (c) Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +-------------------------------------------------------------------------------- +freetype2 + +Copyright 1990, 1994, 1998 The Open Group + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of The Open Group shall not be +used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization from The Open Group. +-------------------------------------------------------------------------------- +re2 + +Copyright 1999-2005 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +freetype2 + +Copyright 2000 Computing Research Labs, New Mexico State University +Copyright 2001-2004, 2011 Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE COMPUTING RESEARCH LAB OR NEW MEXICO STATE UNIVERSITY BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + +Copyright 2000 Computing Research Labs, New Mexico State University +Copyright 2001-2014 + Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE COMPUTING RESEARCH LAB OR NEW MEXICO STATE UNIVERSITY BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + +Copyright 2000 Computing Research Labs, New Mexico State University +Copyright 2001-2015 + Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE COMPUTING RESEARCH LAB OR NEW MEXICO STATE UNIVERSITY BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + +Copyright 2001, 2002, 2012 Francesco Zappa Nardelli + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE COMPUTING RESEARCH LAB OR NEW MEXICO STATE UNIVERSITY BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT +OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +angle + +Copyright 2002 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2003-2009 Google Inc. All rights reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2003-2009 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2003-2010 Google Inc. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2005 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2006 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2006 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2006 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2006-2007 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +dart +double-conversion + +Copyright 2006-2008 the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +skia + +Copyright 2006-2012 The Android Open Source Project +Copyright 2012 Mozilla Foundation + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2007 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2007 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2007-2020 The Khronos Group Inc. +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +skia + +Copyright 2008 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2008 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2008 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +etc1 + +Copyright 2009 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2009 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2009 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2009-2015 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2010 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2010 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +angle + +Copyright 2010 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2010 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +re2 + +Copyright 2010 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +double-conversion + +Copyright 2010 the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +skia + +Copyright 2011 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2011 Google Inc. +Copyright 2012 Mozilla Foundation + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2011 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +angle + +Copyright 2011 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2011 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2011 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright 2011 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2012 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2012 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +angle + +Copyright 2012 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2012 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2012 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +double-conversion + +Copyright 2012 the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +dart + +Copyright 2012, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +node_preamble + +Copyright 2012, the Dart project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +skia + +Copyright 2013 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2013 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +gtest-parallel + +Copyright 2013 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2013 Google Inc. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2013 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2013 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright 2013 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility +skia + +Copyright 2013 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +flutter +tonic + +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +accessibility +dart +flutter +spring_animation +tonic +web_test_fonts + +Copyright 2013 The Flutter Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +flutter + +Copyright 2013 The Flutter Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2013-2020 The Khronos Group Inc. +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-headers + +Copyright 2013-2025 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +skia + +Copyright 2014 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2014 Google Inc. +Copyright 2017 ARM Ltd. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2014 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2014 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright 2014 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2014 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2014 The Bazel Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dart +zlib + +Copyright 2014 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility +angle +dart +skia + +Copyright 2014 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +flutter + +Copyright 2014 The Flutter Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2014 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glfw + +Copyright 2014-2022 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +swiftshader +vulkan-headers + +Copyright 2014-2025 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan + +Copyright 2014-2026 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +skia + +Copyright 2015 Google Inc. + +Use of this source code is governed by a BD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2015 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2015 Google Inc. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2015 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +benchmark +flatbuffers + +Copyright 2015 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2015 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2015 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2015 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility + +Copyright 2015 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +accessibility +brotli +icu +skia + +Copyright 2015 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +shaderc + +Copyright 2015 The Shaderc Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader +vulkan +vulkan-headers + +Copyright 2015-2023 The Khronos Group Inc. +Copyright 2015-2023 Valve Corporation +Copyright 2015-2023 LunarG, Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +swiftshader +vulkan-headers + +Copyright 2015-2025 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan + +Copyright 2015-2026 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +skia + +Copyright 2016 Google Inc. + +Use of this source code is governed by a BD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2016 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file + +-------------------------------------------------------------------------------- +libpng +skia + +Copyright 2016 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2016 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2016 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +benchmark + +Copyright 2016 Ismael Jimenez Martinez. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +benchmark + +Copyright 2016 Ismael Jimenez Martinez. All rights reserved. +Copyright 2017 Roman Lebedev. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2016 Mozilla Foundation + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2016 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2016 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility +icu +skia + +Copyright 2016 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright 2016 The Dart project authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2016 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +re2 + +Copyright 2016 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +shaderc + +Copyright 2016 The Shaderc Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2016 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-cross + +Copyright 2016-2021 The Khronos Group Inc. +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +skia + +Copyright 2017 ARM Ltd. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2017 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +flatbuffers +gtest-parallel + +Copyright 2017 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2017 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2017 Google LLC + +Use of this source code is governed by a BD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2017 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2017 Google LLC + +Use of this source code is governed by a BSD-style license that can be found +in the LICENSE file. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2017 Google LLC +Copyright 2020 Intel Corporation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2017 Google LLC All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright 2017 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2017 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the Chromium source repository LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2017 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility +angle +skia + +Copyright 2017 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright 2017 The Dart project authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2017 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +glslang + +Copyright 2017 The Glslang Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +lunarg-vulkantools +vulkan-tools + +Copyright 2017 The Glslang Authors. All rights reserved. +Copyright (c) 2018-2023 Valve Corporation +Copyright (c) 2018-2023 LunarG, Inc. +Copyright (c) 2023-2023 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +Copyright 2017 The Glslang Authors. All rights reserved. +Copyright (c) 2018-2025 Valve Corporation +Copyright (c) 2018-2025 LunarG, Inc. +Copyright (c) 2023-2023 RasterGrid Kft. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +shaderc + +Copyright 2017 The Shaderc Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +io + +Copyright 2017, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +io + +Copyright 2017, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2017-2020 The Khronos Group Inc. +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2018 Dan Field + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2018 Dan Field. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2018 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +benchmark +flatbuffers + +Copyright 2018 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 Google LLC +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 Google LLC All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +glslang + +Copyright 2018 Google LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2018 IBM + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2018 IBM. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright 2018 The ANGLE Project Authors. All rights reserved. + Use of this source code is governed by a BSD-style license that can be + found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2018 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2018 The Bazel Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +zlib + +Copyright 2018 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the Chromium source repository LICENSE file. +-------------------------------------------------------------------------------- +accessibility +angle +skia + +Copyright 2018 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2018 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright 2018 The Go Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +re2 + +Copyright 2018 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +shaderc + +Copyright 2018 The Shaderc Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2018 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2018 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +libcxx + +Copyright 2018 Ulf Adams +Copyright (c) Microsoft Corporation. All rights reserved. + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2019 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright 2019 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2019 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2019 Google LLC +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2019 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2019 Google LLC and Adobe Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2019 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2019 The ANGLE Project Authors. All rights reserved. + Use of this source code is governed by a BSD-style license that can be + found in the LICENSE file. +-------------------------------------------------------------------------------- +angle +xxhash + +Copyright 2019 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2019 The ANGLE Project. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2019 The ANGLE project authors. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the LICENSE file +-------------------------------------------------------------------------------- +angle + +Copyright 2019 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2019 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2019 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the Chromium source repository LICENSE file. +-------------------------------------------------------------------------------- +accessibility +skia + +Copyright 2019 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +accessibility + +Copyright 2019 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2019 The Flutter Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file +-------------------------------------------------------------------------------- +flutter + +Copyright 2019 The Flutter Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2019 The Fuchsia Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2019 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +angle + +Copyright 2019 The Fuchsia Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright 2019 The Go Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +re2 + +Copyright 2019 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +shaderc + +Copyright 2019 The Shaderc Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2019 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2019 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2019 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +spirv-cross + +Copyright 2019-2021 Hans-Kristian Arntzen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +benchmark +flatbuffers + +Copyright 2020 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2020 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2020 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2020 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2020 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2020 The ANGLE Project. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +abseil-cpp + +Copyright 2020 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2020 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +accessibility +angle +perfetto +skia + +Copyright 2020 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2020 The Flutter Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file +-------------------------------------------------------------------------------- +process_runner + +Copyright 2020 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +process_runner + +Copyright 2020 The Flutter Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2020 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2020 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2020 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2021 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +benchmark +flatbuffers + +Copyright 2021 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2021 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2021 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2021 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2021 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2021 The ANGLE Project. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +libjxl +skia +vulkan-deps + +Copyright 2021 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2021 The Flutter Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2021 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2021 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +io + +Copyright 2021, the Dart project authors. Please see the AUTHORS file +for details. All rights reserved. Use of this source code is governed by a +BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2021-2022 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +vulkan-headers + +Copyright 2021-2025 The Khronos Group Inc. +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +yapf + +Copyright 2022 Bill Wendling, All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +libwebp + +Copyright 2022 Google Inc. All Rights Reserved. + +Use of this source code is governed by a BSD-style license +that can be found in the COPYING file in the root of the source +tree. An additional intellectual property rights grant can be found +in the file PATENTS. All contributing project authors may +be found in the AUTHORS file in the root of the source tree. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2022 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +flutter +skia + +Copyright 2022 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2022 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2022 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2022 IBM + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2022 IBM. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +angle + +Copyright 2022 The ANGLE Project Authors. All rights reserved. + Use of this source code is governed by a BSD-style license that can be + found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2022 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2022 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the Chromium source repository LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2022 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +zlib + +Copyright 2022 The Chromium Authors +Use of this source code is governed by a BSD-style license that can be +found in the chromium source repository LICENSE file. +-------------------------------------------------------------------------------- +dart +skia + +Copyright 2022 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2022 The Flutter Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2022 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +re2 + +Copyright 2022 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +swiftshader + +Copyright 2022 The SwiftShader Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 Google Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2023 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +cpu_features + +Copyright 2023 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 Google LLC +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2023 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2023 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2023 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2023 The Flutter Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2023 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +vulkan-tools +vulkan-utility-libraries + +Copyright 2023 The Khronos Group Inc. +Copyright 2023 Valve Corporation +Copyright 2023 LunarG, Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +re2 + +Copyright 2023 The RE2 Authors. All Rights Reserved. +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +lunarg-vulkantools +vulkan-validation-layers + +Copyright 2023-2024 The Khronos Group Inc. +Copyright 2023-2024 Valve Corporation +Copyright 2023-2024 LunarG, Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +lunarg-vulkantools +vulkan-headers + +Copyright 2023-2025 The Khronos Group Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +swiftshader +vulkan-utility-libraries + +Copyright 2023-2025 The Khronos Group Inc. +Copyright 2023-2025 Valve Corporation +Copyright 2023-2025 LunarG, Inc. + +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +vulkan-headers + +Copyright 2023-2025 The Khronos Group Inc. +SPDX-License-Identifier: Apache-2.0 +-------------------------------------------------------------------------------- +angle + +Copyright 2024 Google Inc. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +flatbuffers + +Copyright 2024 Google Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +skia + +Copyright 2024 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2024 Google LLC +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2024 Google LLC +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2024 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2024 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2024 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2024 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +devtools + +Copyright 2024 The Flutter Authors +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2024 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +dawn +skia + +Copyright 2025 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2025 Google LLC +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2025 Google, LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright 2025 The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +dart + +Copyright 2025 The Dart Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +fuchsia_sdk + +Copyright 2025 The Fuchsia Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +spirv-tools + +Copyright 2025 The Khronos Group Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +ai + +Copyright 2025, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +skia + +Copyright 2026 Google LLC + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + +Copyright 2026 The Android Open Source Project + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright The ANGLE Project Authors. All rights reserved. + Use of this source code is governed by a BSD-style license that can be + found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +angle + +Copyright {copyright_year} The ANGLE Project Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 1998-2004 David Turner and Werner Lemberg +Copyright © 2004,2007,2009 Red Hat, Inc. +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Owen Taylor, Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 1998-2004 David Turner and Werner Lemberg +Copyright © 2004,2007,2009,2010 Red Hat, Inc. +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Owen Taylor, Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 1998-2004 David Turner and Werner Lemberg +Copyright © 2006 Behdad Esfahbod +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007 Chris Wilson +Copyright © 2009,2010 Red Hat, Inc. +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Contributor(s): +Chris Wilson +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2010,2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2010,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2010,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod, Garret Rieger + +-------------------------------------------------------------------------------- +freetype2 +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2012,2013 Google, Inc. +Copyright © 2019, Facebook Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod +Facebook Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009 Red Hat, Inc. +Copyright © 2018,2019,2020 Ebrahim Byagowi +Copyright © 2018 Khaled Hosny + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009,2010 Red Hat, Inc. +Copyright © 2010,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009,2010 Red Hat, Inc. +Copyright © 2010,2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009,2010 Red Hat, Inc. +Copyright © 2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009,2010 Red Hat, Inc. +Copyright © 2012,2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2007,2008,2009,2010 Red Hat, Inc. +Copyright © 2012,2018 Google, Inc. +Copyright © 2019 Facebook, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod +Facebook Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +swiftshader + +Copyright © 2008 Kristian Høgsberg + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice (including the +next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2009 Keith Stribley +Copyright © 2011 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2009 Keith Stribley +Copyright © 2015 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2011 Codethink Limited +Copyright © 2010,2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Codethink Author(s): Ryan Lortie +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2011 Codethink Limited +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Codethink Author(s): Ryan Lortie +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2011 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2011 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod, Roozbeh Pournader + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2015 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009 Red Hat, Inc. +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009,2010 Red Hat, Inc. +Copyright © 2010,2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009,2010 Red Hat, Inc. +Copyright © 2010,2011,2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009,2010 Red Hat, Inc. +Copyright © 2010,2011,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2009,2010 Red Hat, Inc. +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2010 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2010 Red Hat, Inc. +Copyright © 2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2010,2011 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2010,2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2010,2011,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2010,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011 Martin Hosken +Copyright © 2011 SIL International + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011 Martin Hosken +Copyright © 2011 SIL International +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod, Roderick Sheeter + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2012 Google, Inc. +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2012,2013 Google, Inc. +Copyright © 2021 Khaled Hosny + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2012,2014 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2011,2014 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod, Roozbeh Pournader + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2012 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2012 Mozilla Foundation. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Mozilla Author(s): Jonathan Kew + +-------------------------------------------------------------------------------- +swiftshader + +Copyright © 2012 Intel Corporation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice (including the +next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2012,2013 Mozilla Foundation. +Copyright © 2012,2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Mozilla Author(s): Jonathan Kew +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2012,2017 Google, Inc. +Copyright © 2021 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2012,2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2013 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2013 Red Hat, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2014 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2014 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod, Roozbeh Pournader + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2015 Google, Inc. +Copyright © 2019 Adobe Inc. +Copyright © 2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod, Garret Rieger, Roderick Sheeter +Adobe Author(s): Michiharu Ariza + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2015 Mozilla Foundation. +Copyright © 2015 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Mozilla Author(s): Jonathan Kew +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2015-2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Elie Roux +Copyright © 2018 Google, Inc. +Copyright © 2018-2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Seigo Nonaka, Calder Kitagawa + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Google, Inc. +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Sascha Brawer + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Google, Inc. +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Sascha Brawer, Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Google, Inc. +Copyright © 2018 Khaled Hosny +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Sascha Brawer, Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2016 Igalia S.L. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Igalia Author(s): Frédéric Wang + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2017 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2017 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2017 Google, Inc. +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2017 Google, Inc. +Copyright © 2019 Facebook, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod +Facebook Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2017,2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Ebrahim Byagowi +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Ebrahim Byagowi +Copyright © 2020 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Ebrahim Byagowi +Copyright © 2020 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Calder Kitagawa + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Ebrahim Byagowi. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger, Rod Sheeter, Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger, Roderick Sheeter + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Rod Sheeter + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. +Copyright © 2019 Facebook, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod +Facebook Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Google, Inc. +Copyright © 2023 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger, Roderick Sheeter + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza +ifndef HB_CFF1_INTERP_CS_HH +define HB_CFF1_INTERP_CS_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza +ifndef HB_CFF2_INTERP_CS_HH +define HB_CFF2_INTERP_CS_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza +ifndef HB_CFF_INTERP_COMMON_HH +define HB_CFF_INTERP_COMMON_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza +ifndef HB_CFF_INTERP_CS_COMMON_HH +define HB_CFF_INTERP_CS_COMMON_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza +ifndef HB_CFF_INTERP_DICT_COMMON_HH +define HB_CFF_INTERP_DICT_COMMON_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza +ifndef HB_OT_CFF_COMMON_HH +define HB_OT_CFF_COMMON_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2018-2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019 Adobe Inc. +Copyright © 2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019 Adobe, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019 Facebook, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Facebook Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019 Adobe Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Adobe Author(s): Michiharu Ariza + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2019-2020 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2020 Ebrahim Byagowi + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2020 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2020 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +include "hb-ot-var-common.hh" +include "hb-ot-var-hvar-table.hh" +HVAR table data from SourceSerif4Variable-Roman_subset.otf +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2020 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +include "hb-ot-var-cvar-table.hh" +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2021 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2021 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2021 Behdad Esfahbod. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2021 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Red Hat, Inc + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Matthias Clasen + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Red Hat, Inc +Copyright © 2021, 2022 Black Foundry + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Matthias Clasen + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Red Hat, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Matthias Clasen + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +ifndef HB_GEOMETRY_HH +define HB_GEOMETRY_HH +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Matthias Clasen + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2022 Red Hat, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Red Hat Author(s): Matthias Clasen + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2023 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2023 Behdad Esfahbod +Copyright © 1999 David Turner +Copyright © 2005 Werner Lemberg +Copyright © 2013-2015 Alexei Podtelezhnikov + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2023 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2023 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger, Qunxin Liu, Roderick Sheeter + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2023 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Qunxin Liu + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2024 David Corbett + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2024 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2024 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2024 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2025 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2025 Google, Inc. + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Google Author(s): Garret Rieger + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2025 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2026 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +Author(s): Behdad Esfahbod + +-------------------------------------------------------------------------------- +harfbuzz + +Copyright © 2026 Behdad Esfahbod + + This is part of HarfBuzz, a text shaping library. + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +-------------------------------------------------------------------------------- +glslang + +Copyright(C) 2021 Advanced Micro Devices, Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + Neither the name of 3Dlabs Inc. Ltd. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +File: layers/external/inplace_function.h +File: layers/external/parallel_hashmap/phmap_utils.h + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +harfbuzz + +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +-------------------------------------------------------------------------------- +flutter + +License for the Ahem font embedded below is from: +https://www.w3.org/Style/CSS/Test/Fonts/Ahem/COPYING + +The Ahem font in this directory belongs to the public domain. In +jurisdictions that do not recognize public domain ownership of these +files, the following Creative Commons Zero declaration applies: + + + +which is quoted below: + + The person who has associated a work with this document (the "Work") + affirms that he or she (the "Affirmer") is the/an author or owner of + the Work. The Work may be any work of authorship, including a + database. + + The Affirmer hereby fully, permanently and irrevocably waives and + relinquishes all of her or his copyright and related or neighboring + legal rights in the Work available under any federal or state law, + treaty or contract, including but not limited to moral rights, + publicity and privacy rights, rights protecting against unfair + competition and any rights protecting the extraction, dissemination + and reuse of data, whether such rights are present or future, vested + or contingent (the "Waiver"). The Affirmer makes the Waiver for the + benefit of the public at large and to the detriment of the Affirmer's + heirs or successors. + + The Affirmer understands and intends that the Waiver has the effect + of eliminating and entirely removing from the Affirmer's control all + the copyright and related or neighboring legal rights previously held + by the Affirmer in the Work, to that extent making the Work freely + available to the public for any and all uses and purposes without + restriction of any kind, including commercial use and uses in media + and formats or by methods that have not yet been invented or + conceived. Should the Waiver for any reason be judged legally + ineffective in any jurisdiction, the Affirmer hereby grants a free, + full, permanent, irrevocable, nonexclusive and worldwide license for + all her or his copyright and related or neighboring legal rights in + the Work. +-------------------------------------------------------------------------------- +libpng + +PNG Reference Library License version 2 +--------------------------------------- + + * Copyright (c) 1995-2026 The PNG Reference Library Authors. + * Copyright (c) 2018-2026 Cosmin Truta. + * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. + * Copyright (c) 1996-1997 Andreas Dilger. + * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +The software is supplied "as is", without warranty of any kind, +express or implied, including, without limitation, the warranties +of merchantability, fitness for a particular purpose, title, and +non-infringement. In no event shall the Copyright owners, or +anyone distributing the software, be liable for any damages or +other liability, whether in contract, tort or otherwise, arising +from, out of, or in connection with the software, or the use or +other dealings in the software, even if advised of the possibility +of such damage. + +Permission is hereby granted to use, copy, modify, and distribute +this software, or portions hereof, for any purpose, without fee, +subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you + use this software in a product, an acknowledgment in the product + documentation would be appreciated, but is not required. + + 2. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + + +PNG Reference Library License version 1 (for libpng 0.5 through 1.6.35) +----------------------------------------------------------------------- + +libpng versions 1.0.7, July 1, 2000, through 1.6.35, July 15, 2018 are +Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson, are +derived from libpng-1.0.6, and are distributed according to the same +disclaimer and license as libpng-1.0.6 with the following individuals +added to the list of Contributing Authors: + + Simon-Pierre Cadieux + Eric S. Raymond + Mans Rullgard + Cosmin Truta + Gilles Vollant + James Yu + Mandar Sahastrabuddhe + Google Inc. + Vadim Barkov + +and with the following additions to the disclaimer: + + There is no warranty against interference with your enjoyment of + the library or against infringement. There is no warranty that our + efforts or the library will fulfill any of your particular purposes + or needs. This library is provided with all faults, and the entire + risk of satisfactory quality, performance, accuracy, and effort is + with the user. + +Some files in the "contrib" directory and some configure-generated +files that are distributed with libpng have other copyright owners, and +are released under other open source licenses. + +libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are +Copyright (c) 1998-2000 Glenn Randers-Pehrson, are derived from +libpng-0.96, and are distributed according to the same disclaimer and +license as libpng-0.96, with the following individuals added to the +list of Contributing Authors: + + Tom Lane + Glenn Randers-Pehrson + Willem van Schaik + +libpng versions 0.89, June 1996, through 0.96, May 1997, are +Copyright (c) 1996-1997 Andreas Dilger, are derived from libpng-0.88, +and are distributed according to the same disclaimer and license as +libpng-0.88, with the following individuals added to the list of +Contributing Authors: + + John Bowler + Kevin Bracey + Sam Bushell + Magnus Holmgren + Greg Roelofs + Tom Tanner + +Some files in the "scripts" directory have other copyright owners, +but are released under this license. + +libpng versions 0.5, May 1995, through 0.88, January 1996, are +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +For the purposes of this copyright and license, "Contributing Authors" +is defined as the following set of individuals: + + Andreas Dilger + Dave Martindale + Guy Eric Schalnat + Paul Schmidt + Tim Wegner + +The PNG Reference Library is supplied "AS IS". The Contributing +Authors and Group 42, Inc. disclaim all warranties, expressed or +implied, including, without limitation, the warranties of +merchantability and of fitness for any purpose. The Contributing +Authors and Group 42, Inc. assume no liability for direct, indirect, +incidental, special, exemplary, or consequential damages, which may +result from the use of the PNG Reference Library, even if advised of +the possibility of such damage. + +Permission is hereby granted to use, copy, modify, and distribute this +source code, or portions hereof, for any purpose, without fee, subject +to the following restrictions: + + 1. The origin of this source code must not be misrepresented. + + 2. Altered versions must be plainly marked as such and must not + be misrepresented as being the original source. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + +The Contributing Authors and Group 42, Inc. specifically permit, +without fee, and encourage the use of this source code as a component +to supporting the PNG file format in commercial products. If you use +this source code in a product, acknowledgment is not required but would +be appreciated. + +-------------------------------------------------------------------------------- +libpng + +PNG Reference Library License version 2 +--------------------------------------- + + * Copyright (c) 1995-2026 The PNG Reference Library Authors. + * Copyright (c) 2018-2026 Cosmin Truta. + * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. + * Copyright (c) 1996-1997 Andreas Dilger. + * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +The software is supplied "as is", without warranty of any kind, +express or implied, including, without limitation, the warranties +of merchantability, fitness for a particular purpose, title, and +non-infringement. In no event shall the Copyright owners, or +anyone distributing the software, be liable for any damages or +other liability, whether in contract, tort or otherwise, arising +from, out of, or in connection with the software, or the use or +other dealings in the software, even if advised of the possibility +of such damage. + +Permission is hereby granted to use, copy, modify, and distribute +this software, or portions hereof, for any purpose, without fee, +subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you + use this software in a product, an acknowledgment in the product + documentation would be appreciated, but is not required. + + 2. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + + +PNG Reference Library License version 1 (for libpng 0.5 through 1.6.35) +----------------------------------------------------------------------- + +libpng versions 1.0.7, July 1, 2000, through 1.6.35, July 15, 2018 are +Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson, are +derived from libpng-1.0.6, and are distributed according to the same +disclaimer and license as libpng-1.0.6 with the following individuals +added to the list of Contributing Authors: + + Simon-Pierre Cadieux + Eric S. Raymond + Mans Rullgard + Cosmin Truta + Gilles Vollant + James Yu + Mandar Sahastrabuddhe + Google Inc. + Vadim Barkov + +and with the following additions to the disclaimer: + + There is no warranty against interference with your enjoyment of + the library or against infringement. There is no warranty that our + efforts or the library will fulfill any of your particular purposes + or needs. This library is provided with all faults, and the entire + risk of satisfactory quality, performance, accuracy, and effort is + with the user. + +Some files in the "contrib" directory and some configure-generated +files that are distributed with libpng have other copyright owners, and +are released under other open source licenses. + +libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are +Copyright (c) 1998-2000 Glenn Randers-Pehrson, are derived from +libpng-0.96, and are distributed according to the same disclaimer and +license as libpng-0.96, with the following individuals added to the +list of Contributing Authors: + + Tom Lane + Glenn Randers-Pehrson + Willem van Schaik + +libpng versions 0.89, June 1996, through 0.96, May 1997, are +Copyright (c) 1996-1997 Andreas Dilger, are derived from libpng-0.88, +and are distributed according to the same disclaimer and license as +libpng-0.88, with the following individuals added to the list of +Contributing Authors: + + John Bowler + Kevin Bracey + Sam Bushell + Magnus Holmgren + Greg Roelofs + Tom Tanner + +Some files in the "scripts" directory have other copyright owners, +but are released under this license. + +libpng versions 0.5, May 1995, through 0.88, January 1996, are +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +For the purposes of this copyright and license, "Contributing Authors" +is defined as the following set of individuals: + + Andreas Dilger + Dave Martindale + Guy Eric Schalnat + Paul Schmidt + Tim Wegner + +The PNG Reference Library is supplied "AS IS". The Contributing +Authors and Group 42, Inc. disclaim all warranties, expressed or +implied, including, without limitation, the warranties of +merchantability and of fitness for any purpose. The Contributing +Authors and Group 42, Inc. assume no liability for direct, indirect, +incidental, special, exemplary, or consequential damages, which may +result from the use of the PNG Reference Library, even if advised of +the possibility of such damage. + +Permission is hereby granted to use, copy, modify, and distribute this +source code, or portions hereof, for any purpose, without fee, subject +to the following restrictions: + + 1. The origin of this source code must not be misrepresented. + + 2. Altered versions must be plainly marked as such and must not + be misrepresented as being the original source. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + +The Contributing Authors and Group 42, Inc. specifically permit, +without fee, and encourage the use of this source code as a component +to supporting the PNG file format in commercial products. If you use +this source code in a product, acknowledgment is not required but would +be appreciated. + +END OF COPYRIGHT NOTICE, DISCLAIMER, and LICENSE. + +TRADEMARK +========= + +The name "libpng" has not been registered by the Copyright owners +as a trademark in any jurisdiction. However, because libpng has +been distributed and maintained world-wide, continually since 1995, +the Copyright owners claim "common-law trademark protection" in any +jurisdiction where common-law trademark is recognized. + +-------------------------------------------------------------------------------- +libjpeg-turbo + +Portions of this code are based on the PBMPLUS library, which is: + +Copyright (C) 1988 by Jef Poskanzer. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, provided +that the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation. This software is provided "as is" without express or +implied warranty. +-------------------------------------------------------------------------------- +expat + +Read an XML document from standard input and print +element declarations (if any) to standard output. +It must be used with Expat compiled for UTF-8 output. +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2001-2003 Fred L. Drake, Jr. +Copyright (c) 2004-2006 Karl Waclawek +Copyright (c) 2005-2007 Steven Solie +Copyright (c) 2016-2024 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2019 Zhongyuan Zhou +Copyright (c) 2024 Hanno Böck +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +Read an XML document from standard input and print an element +outline on standard output. +Must be used with Expat compiled for UTF-8 output. +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 2000 Clark Cooper +Copyright (c) 2001-2003 Fred L. Drake, Jr. +Copyright (c) 2005-2007 Steven Solie +Copyright (c) 2005-2006 Karl Waclawek +Copyright (c) 2016-2022 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +rapidjson + +Tencent is pleased to support the open source community by making RapidJSON available-> + +Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip-> All rights reserved-> + +Licensed under the MIT License (the "License"); you may not use this file except +in compliance with the License-> You may obtain a copy of the License at + +http://opensource->org/licenses/MIT + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied-> See the License for the +specific language governing permissions and limitations under the License-> +-------------------------------------------------------------------------------- +rapidjson + +Tencent is pleased to support the open source community by making RapidJSON available. + +Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved. + +If you have downloaded a copy of the RapidJSON binary from Tencent, please note that the RapidJSON binary is licensed under the MIT License. +If you have downloaded a copy of the RapidJSON source code from Tencent, please note that RapidJSON source code is licensed under the MIT License, except for the third-party components listed below which are subject to different license terms. Your integration of RapidJSON into your own projects may require compliance with the MIT License, as well as the other licenses applicable to the third-party components included within RapidJSON. + +A copy of the MIT License is included in this file. + +Other dependencies and licenses: + +Open Source Software Licensed Under the BSD License: +-------------------------------------------------------------------- + +The msinttypes r29 +Copyright (c) 2006-2013 Alexander Chemeris +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +* Neither the name of copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Terms of the MIT License: +-------------------------------------------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +fiat + +The Apache License, Version 2.0 (Apache-2.0) + +Copyright 2015-2020 the fiat-crypto authors (see the AUTHORS file) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +-------------------------------------------------------------------------------- +rapidjson + +The above software in this distribution may have been modified by +THL A29 Limited ("Tencent Modifications"). +All Tencent Modifications are Copyright (C) 2015 THL A29 Limited. +-------------------------------------------------------------------------------- +re2 + +The authors of this software are Rob Pike and Ken Thompson. + Copyright (c) 2002 by Lucent Technologies. +Permission to use, copy, modify, and distribute this software for any +purpose without fee is hereby granted, provided that this entire notice +is included in all copies of any software which is or includes a copy +or modification of this software and in all copies of the supporting +documentation for such software. +THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED +WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR LUCENT TECHNOLOGIES MAKE ANY +REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY +OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. +-------------------------------------------------------------------------------- +vulkan-validation-layers + +The majority of files in this project use the Apache 2.0 License. +There are a few exceptions and their license can be found in the source. +Any license deviations from Apache 2.0 are "more permissive" licenses. +Any file without a license in it's source defaults to the repository Apache 2.0 License. + +=========================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-------------------------------------------------------------------------------- +sqlite + +The source code for SQLite is in the public domain. No claim of +copyright is made on any part of the core source code. (The +documentation and test code is a different matter - some sections of +documentation and test logic are governed by open-source licenses.) +All contributors to the SQLite core software have signed affidavits +specifically disavowing any copyright interest in the code. This means +that anybody is able to legally do anything they want with the SQLite +source code. + +There are other SQL database engines with liberal licenses that allow +the code to be broadly and freely used. But those other engines are +still governed by copyright law. SQLite is different in that copyright +law simply does not apply. + +The source code files for other SQL database engines typically begin +with a comment describing your legal rights to view and copy that +file. The SQLite source code contains no license since it is not +governed by copyright. Instead of a license, the SQLite source code +offers a blessing: + +May you do good and not evil +May you find forgiveness for yourself and forgive others +May you share freely, never taking more than you give. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This code is loosely based on ppmtogif from the PBMPLUS distribution +of Feb. 1991. That file contains the following copyright notice: + Based on GIFENCODE by David Rowley . + Lempel-Ziv compression based on "compress" by Spencer W. Thomas et al. + Copyright (C) 1989 by Jef Poskanzer. + Permission to use, copy, modify, and distribute this software and its + documentation for any purpose and without fee is hereby granted, provided + that the above copyright notice appear in all copies and that both that + copyright notice and this permission notice appear in supporting + documentation. This software is provided "as is" without express or + implied warranty. + +We are also required to state that + "The Graphics Interchange Format(c) is the Copyright property of + CompuServe Incorporated. GIF(sm) is a Service Mark property of + CompuServe Incorporated." +-------------------------------------------------------------------------------- +expat + +This file is included (from xmltok.c, 1-3 times depending on XML_MIN_SIZE)! +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2002-2016 Karl Waclawek +Copyright (c) 2016-2022 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2018 Benjamin Peterson +Copyright (c) 2018 Anton Maklakov +Copyright (c) 2019 David Loffredo +Copyright (c) 2020 Boris Kolpackov +Copyright (c) 2022 Martin Ettl +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +This file is included! +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Greg Stein +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2002-2006 Karl Waclawek +Copyright (c) 2017-2021 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +icu + +This file was generated from RFC 3454 (http://www.ietf.org/rfc/rfc3454.txt) +Copyright (C) The Internet Society (2002). All Rights Reserved. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1994, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1995, Thomas G. Lane. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code +relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code and +information relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +Modified 2009 by Guido Vollbeding. +It was modified by The libjpeg-turbo Project to include only code and +information relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2009, 2014-2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2009, 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2009-2012, 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2010, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2009-2012, 2015, D. R. Commander. +Copyright (C) 2014, MIPS Technologies, Inc., California. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2010, 2015-2016, D. R. Commander. +Copyright (C) 2014, MIPS Technologies, Inc., California. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2014, MIPS Technologies, Inc., California. +Copyright (C) 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modifications: +Copyright (C) 2013, Linaro Limited. +Copyright (C) 2014-2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 1997-2009 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2009, 2011, 2014-2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 1997-2009 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2015-2016, D. R. Commander. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 2002-2009 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2009-2011, 2016, D. R. Commander. +Copyright (C) 2013, Linaro Limited. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 2003-2010 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 2009 by Bill Allombert, Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2015, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 2011 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2009, 2011-2012, 2014-2015, D. R. Commander. +Copyright (C) 2013, Linaro Limited. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +Modified 2013 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010-2011, 2013-2016, D. R. Commander. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2009, 2011, 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2009-2011, 2014-2016, D. R. Commander. +Copyright (C) 2015, Matthieu Darbois. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2009-2011, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2016, D. R. Commander. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2010-2011, 2015-2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +Modified 2002-2009 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2009-2011, 2013-2014, 2016, D. R. Commander. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +Modified 2003-2008 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2009-2011, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +Modified 2003-2010 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +Modified 2003-2011 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2013-2014, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2012, 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-1998, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2013, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-2012, Thomas G. Lane, Guido Vollbeding. +It was modified by The libjpeg-turbo Project to include only information +relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1991-2012, Thomas G. Lane, Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2012-2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1992-1996, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code and +information relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1992-1997, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code and +information relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994, Thomas G. Lane. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +Modified 2002-2010 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2010, 2015, D. R. Commander. +Copyright (C) 2013, MIPS Technologies, Inc., California. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +Modified 2009-2010 by Guido Vollbeding. +libjpeg-turbo Modifications: +Modified 2011 by Siarhei Siamashka. +Copyright (C) 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +Modified 2009-2011 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2011, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +Modified 2009-2011 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2013, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +Modified 2009-2012 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2011, 2014, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +Modified 2009-2012 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2013, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 1999-2006, MIYASAKA Masaru. +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2011, 2014-2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2015-2016, D. R. Commander. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2011, 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2013, Linaro Limited. +Copyright (C) 2014-2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1996, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2009, 2011, 2014-2015, D. R. Commander. +Copyright (C) 2013, Linaro Limited. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code and +information relevant to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +Modified 1997-2009 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2014, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +Modified 2009 by Bill Allombert, Guido Vollbeding. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2014, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +Copyright (C) 2010, 2015-2016, D. R. Commander. +Copyright (C) 2015, Google, Inc. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright 2009 Pierre Ossman for Cendio AB +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1998, Thomas G. Lane. +Modified 2003-2010 by Guido Vollbeding. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1998, Thomas G. Lane. +Modified 2010 by Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2014, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1998, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1994-1998, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2016, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1995-1997, Thomas G. Lane. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1995-1997, Thomas G. Lane. +libjpeg-turbo Modifications: +Copyright (C) 2015, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1995-1998, Thomas G. Lane. +Modified 2000-2009 by Guido Vollbeding. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1995-2010, Thomas G. Lane, Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010, 2014, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1997-2011, Thomas G. Lane, Guido Vollbeding. +It was modified by The libjpeg-turbo Project to include only code relevant +to libjpeg-turbo. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +libjpeg-turbo + +This file was part of the Independent JPEG Group's software: +Copyright (C) 1997-2011, Thomas G. Lane, Guido Vollbeding. +libjpeg-turbo Modifications: +Copyright (C) 2010, D. R. Commander. +For conditions of distribution and use, see the accompanying README.ijg +file. +-------------------------------------------------------------------------------- +expat + +This is simple demonstration of how to use expat. This program +reads an XML document from standard input and writes a line with +the name of each element to standard output indenting child +elements by one tab stop more than their parent element. +It must be used with Expat compiled for UTF-8 output. +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2001-2003 Fred L. Drake, Jr. +Copyright (c) 2004-2006 Karl Waclawek +Copyright (c) 2005-2007 Steven Solie +Copyright (c) 2016-2022 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2019 Zhongyuan Zhou +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +freetype2 + +This software was written by Alexander Peslyak in 2001. No copyright is +claimed, and the software is hereby placed in the public domain. +In case this attempt to disclaim copyright and place the software in the +public domain is deemed null and void, then the software is +Copyright (c) 2001 Alexander Peslyak and it is hereby released to the +general public under the following terms: + +Redistribution and use in source and binary forms, with or without +modification, are permitted. + +There's ABSOLUTELY NO WARRANTY, express or implied. +-------------------------------------------------------------------------------- +icu + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2016-2025 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +---------------------------------------------------------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +---------------------------------------------------------------------- + +ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +---------------------------------------------------------------------- + +Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +---------------------------------------------------------------------- + +Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (C) 2016 and later: Unicode, Inc. and others. + # License & terms of use: http://www.unicode.org/copyright.html + # Copyright (c) 2015 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: https://github.com/rober42539/lao-dictionary + # Dictionary: https://github.com/rober42539/lao-dictionary/laodict.txt + # License: https://github.com/rober42539/lao-dictionary/LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary version of Nov 22, 2020 + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in binary + # form must reproduce the above copyright notice, this list of conditions and + # the following disclaimer in the documentation and/or other materials + # provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + +---------------------------------------------------------------------- + +Google double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---------------------------------------------------------------------- + +JSON parsing library (nlohmann/json) + +File: vendor/json/upstream/single_include/nlohmann/json.hpp (only for ICU4C) + +MIT License + +Copyright (c) 2013-2022 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------------- + + +File: sorttable.js (only for ICU4J) + +The MIT Licence, for code from kryogenix.org + +Code downloaded from the Browser Experiments section of kryogenix.org is +licenced under the so-called MIT licence. The licence is below. + +Copyright (c) 1997-date Stuart Langridge + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- +libcxx + +UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE + +See Terms of Use +for definitions of Unicode Inc.'s Data Files and Software. + +NOTICE TO USER: Carefully read the following legal agreement. +BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S +DATA FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), +YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. +IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE +THE DATA FILES OR SOFTWARE. + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1991-2022 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. +-------------------------------------------------------------------------------- +llvm_libc + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2007-2019 University of Illinois at Urbana-Champaign. +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. +-------------------------------------------------------------------------------- +libcxx +libcxxabi + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2009-2019 by the contributors listed in CREDITS.TXT + +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2000-2004 Fred L. Drake, Jr. +Copyright (c) 2001-2002 Greg Stein +Copyright (c) 2002-2006 Karl Waclawek +Copyright (c) 2016 Cristian Rodríguez +Copyright (c) 2016-2019 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2018 Yury Gribov +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2000-2005 Fred L. Drake, Jr. +Copyright (c) 2001-2002 Greg Stein +Copyright (c) 2002-2016 Karl Waclawek +Copyright (c) 2016-2025 Sebastian Pipping +Copyright (c) 2016 Cristian Rodríguez +Copyright (c) 2016 Thomas Beutlich +Copyright (c) 2017 Rhodri James +Copyright (c) 2022 Thijs Schreijer +Copyright (c) 2023 Hanno Böck +Copyright (c) 2023 Sony Corporation / Snild Dolkow +Copyright (c) 2024 Taichi Haradaguchi <20001722@ymail.ne.jp> +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2001-2002 Fred L. Drake, Jr. +Copyright (c) 2006 Karl Waclawek +Copyright (c) 2016-2017 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2001-2003 Fred L. Drake, Jr. +Copyright (c) 2002 Greg Stein +Copyright (c) 2002-2016 Karl Waclawek +Copyright (c) 2005-2009 Steven Solie +Copyright (c) 2016-2024 Sebastian Pipping +Copyright (c) 2016 Pascal Cuoq +Copyright (c) 2016 Don Lewis +Copyright (c) 2017 Rhodri James +Copyright (c) 2017 Alexander Bluhm +Copyright (c) 2017 Benbuck Nason +Copyright (c) 2017 José Gutiérrez de la Concha +Copyright (c) 2019 David Loffredo +Copyright (c) 2021 Donghee Na +Copyright (c) 2022 Martin Ettl +Copyright (c) 2022 Sean McBride +Copyright (c) 2023 Hanno Böck +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2001-2003 Fred L. Drake, Jr. +Copyright (c) 2004-2009 Karl Waclawek +Copyright (c) 2005-2007 Steven Solie +Copyright (c) 2016-2023 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2019 David Loffredo +Copyright (c) 2020 Joe Orton +Copyright (c) 2020 Kleber Tarcísio +Copyright (c) 2021 Tim Bray +Copyright (c) 2022 Martin Ettl +Copyright (c) 2022 Sean McBride +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2001-2004 Fred L. Drake, Jr. +Copyright (c) 2002-2009 Karl Waclawek +Copyright (c) 2016-2017 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2017 Franek Korta +Copyright (c) 2022 Sean McBride +Copyright (c) 2025 Hanno Böck +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2002-2005 Karl Waclawek +Copyright (c) 2016-2024 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2005 Karl Waclawek +Copyright (c) 2016-2023 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2005-2006 Karl Waclawek +Copyright (c) 2016-2019 Sebastian Pipping +Copyright (c) 2019 David Loffredo +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2016-2017 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2016-2022 Sebastian Pipping +Copyright (c) 2022 Martin Ettl +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2016-2024 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2017 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Greg Stein +Copyright (c) 2002-2006 Karl Waclawek +Copyright (c) 2002-2003 Fred L. Drake, Jr. +Copyright (c) 2005-2009 Steven Solie +Copyright (c) 2016-2023 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2019 David Loffredo +Copyright (c) 2021 Donghee Na +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Karl Waclawek +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2017-2024 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002-2003 Fred L. Drake, Jr. +Copyright (c) 2004-2006 Karl Waclawek +Copyright (c) 2005-2007 Steven Solie +Copyright (c) 2016-2023 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Copyright (c) 2019 David Loffredo +Copyright (c) 2021 Donghee Na +Copyright (c) 2024 Hanno Böck +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2017-2019 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2016-2017 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2016-2018 Sebastian Pipping +Copyright (c) 2018 Marco Maggi +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2016-2021 Sebastian Pipping +Copyright (c) 2017 Rhodri James +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1999-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Fred L. Drake, Jr. +Copyright (c) 2007 Karl Waclawek +Copyright (c) 2017 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 2000 Clark Cooper +Copyright (c) 2002 Greg Stein +Copyright (c) 2005 Karl Waclawek +Copyright (c) 2017-2023 Sebastian Pipping +Copyright (c) 2023 Orgad Shaneh +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 2000 Clark Cooper +Copyright (c) 2017 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +expat + +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 2022 Mark Brand +Copyright (c) 2025 Sebastian Pipping +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +inja + +___ _ Version 3.3 +|_ _|_ __ (_) __ _ https://github.com/pantor/inja +| || '_ \ | |/ _` | Licensed under the MIT License . +| || | | || | (_| | +|___|_| |_|/ |\__,_| Copyright (c) 2018-2021 Lars Berscheid +|__/ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- +expat + +d19ae032c224863c1527ba44d228cc34b99192c3a4c5a27af1f4e054d45ee031 (2.7.1+) +__ __ _ +___\ \/ /_ __ __ _| |_ +/ _ \\ /| '_ \ / _` | __| +| __// \| |_) | (_| | |_ +\___/_/\_\ .__/ \__,_|\__| +|_| XML parser + +Copyright (c) 1997-2000 Thai Open Source Software Center Ltd +Copyright (c) 2000 Clark Cooper +Copyright (c) 2000-2006 Fred L. Drake, Jr. +Copyright (c) 2001-2002 Greg Stein +Copyright (c) 2002-2016 Karl Waclawek +Copyright (c) 2005-2009 Steven Solie +Copyright (c) 2016 Eric Rahm +Copyright (c) 2016-2025 Sebastian Pipping +Copyright (c) 2016 Gaurav +Copyright (c) 2016 Thomas Beutlich +Copyright (c) 2016 Gustavo Grieco +Copyright (c) 2016 Pascal Cuoq +Copyright (c) 2016 Ed Schouten +Copyright (c) 2017-2022 Rhodri James +Copyright (c) 2017 Václav Slavík +Copyright (c) 2017 Viktor Szakats +Copyright (c) 2017 Chanho Park +Copyright (c) 2017 Rolf Eike Beer +Copyright (c) 2017 Hans Wennborg +Copyright (c) 2018 Anton Maklakov +Copyright (c) 2018 Benjamin Peterson +Copyright (c) 2018 Marco Maggi +Copyright (c) 2018 Mariusz Zaborski +Copyright (c) 2019 David Loffredo +Copyright (c) 2019-2020 Ben Wagner +Copyright (c) 2019 Vadim Zeitlin +Copyright (c) 2021 Donghee Na +Copyright (c) 2022 Samanta Navarro +Copyright (c) 2022 Jeffrey Walton +Copyright (c) 2022 Jann Horn +Copyright (c) 2022 Sean McBride +Copyright (c) 2023 Owain Davies +Copyright (c) 2023-2024 Sony Corporation / Snild Dolkow +Copyright (c) 2024-2025 Berkay Eren Ürün +Copyright (c) 2024 Hanno Böck +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +libjpeg-turbo + +libjpeg-turbo Licenses +====================== + +libjpeg-turbo is covered by three compatible BSD-style open source licenses: + +- The IJG (Independent JPEG Group) License, which is listed in + [README.ijg](README.ijg) + + This license applies to the libjpeg API library and associated programs + (any code inherited from libjpeg, and any modifications to that code.) + +- The Modified (3-clause) BSD License, which is listed in + [turbojpeg.c](turbojpeg.c) + + This license covers the TurboJPEG API library and associated programs. + +- The zlib License, which is listed in [simd/jsimdext.inc](simd/jsimdext.inc) + + This license is a subset of the other two, and it covers the libjpeg-turbo + SIMD extensions. + + +Complying with the libjpeg-turbo Licenses +========================================= + +This section provides a roll-up of the libjpeg-turbo licensing terms, to the +best of our understanding. + +1. If you are distributing a modified version of the libjpeg-turbo source, + then: + + 1. You cannot alter or remove any existing copyright or license notices + from the source. + + **Origin** + - Clause 1 of the IJG License + - Clause 1 of the Modified BSD License + - Clauses 1 and 3 of the zlib License + + 2. You must add your own copyright notice to the header of each source + file you modified, so others can tell that you modified that file (if + there is not an existing copyright header in that file, then you can + simply add a notice stating that you modified the file.) + + **Origin** + - Clause 1 of the IJG License + - Clause 2 of the zlib License + + 3. You must include the IJG README file, and you must not alter any of the + copyright or license text in that file. + + **Origin** + - Clause 1 of the IJG License + +2. If you are distributing only libjpeg-turbo binaries without the source, or + if you are distributing an application that statically links with + libjpeg-turbo, then: + + 1. Your product documentation must include a message stating: + + This software is based in part on the work of the Independent JPEG + Group. + + **Origin** + - Clause 2 of the IJG license + + 2. If your binary distribution includes or uses the TurboJPEG API, then + your product documentation must include the text of the Modified BSD + License. + + **Origin** + - Clause 2 of the Modified BSD License + +3. You cannot use the name of the IJG or The libjpeg-turbo Project or the + contributors thereof in advertising, publicity, etc. + + **Origin** + - IJG License + - Clause 3 of the Modified BSD License + +4. The IJG and The libjpeg-turbo Project do not warrant libjpeg-turbo to be + free of defects, nor do we accept any liability for undesirable + consequences resulting from your use of the software. + + **Origin** + - IJG License + - Modified BSD License + - zlib License + +-------------------------------------------------------------------------------- +libjpeg-turbo + +libjpeg-turbo note: This file has been modified by The libjpeg-turbo Project +to include only information relevant to libjpeg-turbo, to wordsmith certain +sections, and to remove impolitic language that existed in the libjpeg v8 +README. It is included only for reference. Please see README.md for +information specific to libjpeg-turbo. + + +The Independent JPEG Group's JPEG software +========================================== + +This distribution contains a release of the Independent JPEG Group's free JPEG +software. You are welcome to redistribute this software and to use it for any +purpose, subject to the conditions under LEGAL ISSUES, below. + +This software is the work of Tom Lane, Guido Vollbeding, Philip Gladstone, +Bill Allombert, Jim Boucher, Lee Crocker, Bob Friesenhahn, Ben Jackson, +Julian Minguillon, Luis Ortiz, George Phillips, Davide Rossi, Ge' Weijers, +and other members of the Independent JPEG Group. + +IJG is not affiliated with the ISO/IEC JTC1/SC29/WG1 standards committee +(also known as JPEG, together with ITU-T SG16). + + +DOCUMENTATION ROADMAP +===================== + +This file contains the following sections: + +OVERVIEW General description of JPEG and the IJG software. +LEGAL ISSUES Copyright, lack of warranty, terms of distribution. +REFERENCES Where to learn more about JPEG. +ARCHIVE LOCATIONS Where to find newer versions of this software. +FILE FORMAT WARS Software *not* to get. +TO DO Plans for future IJG releases. + +Other documentation files in the distribution are: + +User documentation: + usage.txt Usage instructions for cjpeg, djpeg, jpegtran, + rdjpgcom, and wrjpgcom. + *.1 Unix-style man pages for programs (same info as usage.txt). + wizard.txt Advanced usage instructions for JPEG wizards only. + change.log Version-to-version change highlights. +Programmer and internal documentation: + libjpeg.txt How to use the JPEG library in your own programs. + example.c Sample code for calling the JPEG library. + structure.txt Overview of the JPEG library's internal structure. + coderules.txt Coding style rules --- please read if you contribute code. + +Please read at least usage.txt. Some information can also be found in the JPEG +FAQ (Frequently Asked Questions) article. See ARCHIVE LOCATIONS below to find +out where to obtain the FAQ article. + +If you want to understand how the JPEG code works, we suggest reading one or +more of the REFERENCES, then looking at the documentation files (in roughly +the order listed) before diving into the code. + + +OVERVIEW +======== + +This package contains C software to implement JPEG image encoding, decoding, +and transcoding. JPEG (pronounced "jay-peg") is a standardized compression +method for full-color and grayscale images. JPEG's strong suit is compressing +photographic images or other types of images that have smooth color and +brightness transitions between neighboring pixels. Images with sharp lines or +other abrupt features may not compress well with JPEG, and a higher JPEG +quality may have to be used to avoid visible compression artifacts with such +images. + +JPEG is lossy, meaning that the output pixels are not necessarily identical to +the input pixels. However, on photographic content and other "smooth" images, +very good compression ratios can be obtained with no visible compression +artifacts, and extremely high compression ratios are possible if you are +willing to sacrifice image quality (by reducing the "quality" setting in the +compressor.) + +This software implements JPEG baseline, extended-sequential, and progressive +compression processes. Provision is made for supporting all variants of these +processes, although some uncommon parameter settings aren't implemented yet. +We have made no provision for supporting the hierarchical or lossless +processes defined in the standard. + +We provide a set of library routines for reading and writing JPEG image files, +plus two sample applications "cjpeg" and "djpeg", which use the library to +perform conversion between JPEG and some other popular image file formats. +The library is intended to be reused in other applications. + +In order to support file conversion and viewing software, we have included +considerable functionality beyond the bare JPEG coding/decoding capability; +for example, the color quantization modules are not strictly part of JPEG +decoding, but they are essential for output to colormapped file formats or +colormapped displays. These extra functions can be compiled out of the +library if not required for a particular application. + +We have also included "jpegtran", a utility for lossless transcoding between +different JPEG processes, and "rdjpgcom" and "wrjpgcom", two simple +applications for inserting and extracting textual comments in JFIF files. + +The emphasis in designing this software has been on achieving portability and +flexibility, while also making it fast enough to be useful. In particular, +the software is not intended to be read as a tutorial on JPEG. (See the +REFERENCES section for introductory material.) Rather, it is intended to +be reliable, portable, industrial-strength code. We do not claim to have +achieved that goal in every aspect of the software, but we strive for it. + +We welcome the use of this software as a component of commercial products. +No royalty is required, but we do ask for an acknowledgement in product +documentation, as described under LEGAL ISSUES. + + +LEGAL ISSUES +============ + +In plain English: + +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2016, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + + +The Unix configuration script "configure" was produced with GNU Autoconf. +It is copyright by the Free Software Foundation but is freely distributable. +The same holds for its supporting scripts (config.guess, config.sub, +ltmain.sh). Another support script, install-sh, is copyright by X Consortium +but is also freely distributable. + +The IJG distribution formerly included code to read and write GIF files. +To avoid entanglement with the Unisys LZW patent (now expired), GIF reading +support has been removed altogether, and the GIF writer has been simplified +to produce "uncompressed GIFs". This technique does not use the LZW +algorithm; the resulting GIF files are larger than usual, but are readable +by all standard GIF decoders. + +We are required to state that + "The Graphics Interchange Format(c) is the Copyright property of + CompuServe Incorporated. GIF(sm) is a Service Mark property of + CompuServe Incorporated." + + +REFERENCES +========== + +We recommend reading one or more of these references before trying to +understand the innards of the JPEG software. + +The best short technical introduction to the JPEG compression algorithm is + Wallace, Gregory K. "The JPEG Still Picture Compression Standard", + Communications of the ACM, April 1991 (vol. 34 no. 4), pp. 30-44. +(Adjacent articles in that issue discuss MPEG motion picture compression, +applications of JPEG, and related topics.) If you don't have the CACM issue +handy, a PDF file containing a revised version of Wallace's article is +available at http://www.ijg.org/files/Wallace.JPEG.pdf. The file (actually +a preprint for an article that appeared in IEEE Trans. Consumer Electronics) +omits the sample images that appeared in CACM, but it includes corrections +and some added material. Note: the Wallace article is copyright ACM and IEEE, +and it may not be used for commercial purposes. + +A somewhat less technical, more leisurely introduction to JPEG can be found in +"The Data Compression Book" by Mark Nelson and Jean-loup Gailly, published by +M&T Books (New York), 2nd ed. 1996, ISBN 1-55851-434-1. This book provides +good explanations and example C code for a multitude of compression methods +including JPEG. It is an excellent source if you are comfortable reading C +code but don't know much about data compression in general. The book's JPEG +sample code is far from industrial-strength, but when you are ready to look +at a full implementation, you've got one here... + +The best currently available description of JPEG is the textbook "JPEG Still +Image Data Compression Standard" by William B. Pennebaker and Joan L. +Mitchell, published by Van Nostrand Reinhold, 1993, ISBN 0-442-01272-1. +Price US$59.95, 638 pp. The book includes the complete text of the ISO JPEG +standards (DIS 10918-1 and draft DIS 10918-2). + +The original JPEG standard is divided into two parts, Part 1 being the actual +specification, while Part 2 covers compliance testing methods. Part 1 is +titled "Digital Compression and Coding of Continuous-tone Still Images, +Part 1: Requirements and guidelines" and has document numbers ISO/IEC IS +10918-1, ITU-T T.81. Part 2 is titled "Digital Compression and Coding of +Continuous-tone Still Images, Part 2: Compliance testing" and has document +numbers ISO/IEC IS 10918-2, ITU-T T.83. + +The JPEG standard does not specify all details of an interchangeable file +format. For the omitted details we follow the "JFIF" conventions, revision +1.02. JFIF 1.02 has been adopted as an Ecma International Technical Report +and thus received a formal publication status. It is available as a free +download in PDF format from +http://www.ecma-international.org/publications/techreports/E-TR-098.htm. +A PostScript version of the JFIF document is available at +http://www.ijg.org/files/jfif.ps.gz. There is also a plain text version at +http://www.ijg.org/files/jfif.txt.gz, but it is missing the figures. + +The TIFF 6.0 file format specification can be obtained by FTP from +ftp://ftp.sgi.com/graphics/tiff/TIFF6.ps.gz. The JPEG incorporation scheme +found in the TIFF 6.0 spec of 3-June-92 has a number of serious problems. +IJG does not recommend use of the TIFF 6.0 design (TIFF Compression tag 6). +Instead, we recommend the JPEG design proposed by TIFF Technical Note #2 +(Compression tag 7). Copies of this Note can be obtained from +http://www.ijg.org/files/. It is expected that the next revision +of the TIFF spec will replace the 6.0 JPEG design with the Note's design. +Although IJG's own code does not support TIFF/JPEG, the free libtiff library +uses our library to implement TIFF/JPEG per the Note. + + +ARCHIVE LOCATIONS +================= + +The "official" archive site for this software is www.ijg.org. +The most recent released version can always be found there in +directory "files". + +The JPEG FAQ (Frequently Asked Questions) article is a source of some +general information about JPEG. +It is available on the World Wide Web at http://www.faqs.org/faqs/jpeg-faq/ +and other news.answers archive sites, including the official news.answers +archive at rtfm.mit.edu: ftp://rtfm.mit.edu/pub/usenet/news.answers/jpeg-faq/. +If you don't have Web or FTP access, send e-mail to mail-server@rtfm.mit.edu +with body + send usenet/news.answers/jpeg-faq/part1 + send usenet/news.answers/jpeg-faq/part2 + + +FILE FORMAT WARS +================ + +The ISO/IEC JTC1/SC29/WG1 standards committee (also known as JPEG, together +with ITU-T SG16) currently promotes different formats containing the name +"JPEG" which are incompatible with original DCT-based JPEG. IJG therefore does +not support these formats (see REFERENCES). Indeed, one of the original +reasons for developing this free software was to help force convergence on +common, interoperable format standards for JPEG files. +Don't use an incompatible file format! +(In any case, our decoder will remain capable of reading existing JPEG +image files indefinitely.) + + +TO DO +===== + +Please send bug reports, offers of help, etc. to jpeg-info@jpegclub.org. + +-------------------------------------------------------------------------------- +zlib + +version 1.2.12, March 27th, 2022 + +Copyright (C) 1995-2022 Jean-loup Gailly and Mark Adler + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +freetype2 +zlib + +version 1.3.1, January 22nd, 2024 + +Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +-------------------------------------------------------------------------------- +xxhash + +xxHash Library +Copyright (c) 2012-2014, Yann Collet +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + + Copyright (C) 1999-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + + Copyright (C) 1999-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + + Copyright (C) 2001, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + +Copyright (C) 2002-2006 IBM, Inc. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1996-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1996-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1996-2013, International Business Machines Corporation + and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1996-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1996-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2005, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1997-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2004, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2005, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2008, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1998-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2001, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2003, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2004, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2010, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2010, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2010, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2013, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2014 International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2016 International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 1999-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2003, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2008, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2010, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2000-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2008, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2001-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2003, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2010, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2011 International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2004, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2007, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2009, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2003-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2004-2005, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2004-2007, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2004-2010, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2004-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2004-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2005, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2005-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2005-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2005-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2007, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2007-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2007-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2007-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2008-2011, International Business Machines + Corporation, Google and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2008-2011, International Business Machines + Corporation, Google and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2008-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2008-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2009-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2011-2014 International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2012,2014 International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2012-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2012-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2013-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + + Copyright (C) 2016, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (C) 2001-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (C) 2001-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (C) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (C) 2003-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (C) 2003-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (C) 2009-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (c) 1999-2002, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (c) 1999-2003, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + +Copyright (c) 1999-2010, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1996-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1996-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1996-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1996-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1996-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1996-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2009,2014 International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2010, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2011,2014-2015 International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2012, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1997-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1998-2005, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1998-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1998-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2005, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2006, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2006, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2006,2013 IBM Corp. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2007, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2009, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2014 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2015 International Business Machines + Corporation and others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2016 International Business Machines Corporation + and others. All rights reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2016 International Business Machines Corporation + and others. All rights reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 1999-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2004, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2006, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2000-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2006, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2007, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2008,2010 IBM and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2010, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2011 IBM and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2011,2014 IBM and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2012, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2014 IBM and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2014 International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2015 IBM and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2001-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2015 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016 International Business Machines Corporation + and others. All rights reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2003, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2006, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2013, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2004-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2004-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2004-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2004-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2006, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2008, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2012, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2013, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2013, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2015, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2005-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2006 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2006, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2008-2011, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2008-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2008-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2009-2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2009-2014 International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2009-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2009-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2009-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2012, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2012, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2012,2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2010-2016, International Business Machines Corporation and + others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2011-2012, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2011-2013, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2011-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2011-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2012 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2012-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2013, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2013-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2014, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2014-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2015-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2000-2005, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2000-2007, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2007, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2012, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2002-2005, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2002-2010, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2002-2011, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2002-2012, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2002-2014, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2002-2016, International Business Machines + Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2008 International Business Machines Corporation + and others. All rights reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2008, International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2011, International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2014 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016 International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2002-2016, International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2003-2010, International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (C) 2004-2015, International Business Machines Corporation and others. + All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + + Copyright (c) 2001-2005, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2014, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2014, International Business Machines Corporation and others. +All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2015, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1996-2016, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2010, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2011, International Business Machines Corporation and others. +All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2012, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2013, International Business Machines +Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2013, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2015, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2015, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2015, International Business Machines Corporation and others. +All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2015, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1997-2016, International Business Machines Corporation and others. +All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1998-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1998-2012, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1998-2016, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2007, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2010, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2011, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2013, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2016, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2016, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 1999-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2000-2004, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2011, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2011, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2011, International Business Machines Corporation. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2014, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2014, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2014, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2014, International Business Machines Corporation. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2001-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2002-2005, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2002-2014, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003 - 2008, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003 - 2009, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003 - 2013, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003-2008, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003-2009,2012,2016 International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003-2013, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003-2013, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003-2015, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2003-2016, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2004 - 2008, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2004-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2006-2012, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2006-2014, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2006-2016, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2008, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2008, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2013, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2013, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2014, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2014, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2014, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2016, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2007-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008, Google, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2009, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2013, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2013, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2014, Google, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2014, Google, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2015, Google, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2015, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2015, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2016, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2008-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2010, Google, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2010, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2011, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2013, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2014, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2015, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2016, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2016, International Business Machines Corporation, +Google, and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2011, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2012, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2012,2015 International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2013, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2014, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2014, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2015, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2010-2016, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2011-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2011-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2011-2016, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2011-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2012-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2012-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2012-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2014, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2014, International Business Machines Corporation and others. +All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2015, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2013-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2016, International Business Machines Corporation and +others. +All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2014-2016, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2015, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2015, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2015-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2015-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1996-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1996-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1996-2015, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1996-2015, International Business Machines Corporation and others. +All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1996-2016, International Business Machines Corporation + and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1996-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1997-2012, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 1999-2002, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2001-2012, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2004, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2006, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2007, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2011, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2014, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2002-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2003, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2003-2004, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2003-2008, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2003-2011, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2003-2013, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2003-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2004, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2004-2006, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2004-2014 International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2004-2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2004-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2004-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2008-2015, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2014, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (c) 2014-2016, International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2000-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2008, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2008-2011, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2008-2012, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2008-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2008-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2009-2013, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2009-2015, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + Copyright (C) 2009-2016, International Business Machines + Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 1996-2008, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 1996-2012, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 1997-2005, International Business Machines Corporation and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 1997-2013, International Business Machines Corporation and others. +All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 1997-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2001-2005, International Business Machines Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2001-2011, International Business Machines Corporation +and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2002-2016 International Business Machines Corporation + and others. All rights reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2003-2014, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2007-2013, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2008, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2008-2016, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2009-2011, International Business Machines +Corporation and others. All Rights Reserved. + +Copyright 2007 Google Inc. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2009-2012, International Business Machines +Corporation and others. All Rights Reserved. + +Copyright 2007 Google Inc. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2009-2013, International Business Machines +Corporation and others. All Rights Reserved. + +Copyright 2001 and onwards Google Inc. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2009-2013, International Business Machines +Corporation and others. All Rights Reserved. + +Copyright 2004 and onwards Google Inc. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) 2015, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (C) {1999-2001}, International Business Machines Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1997-2011, International Business Machines Corporation and +others. All Rights Reserved. +Copyright (C) 2010 , Yahoo! Inc. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1997-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1997-2012, International Business Machines Corporation and +others. All Rights Reserved. +Copyright (C) 2010 , Yahoo! Inc. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1997-2015, International Business Machines Corporation and +others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1997-2016, International Business Machines Corporation +and others. All Rights Reserved. + + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1999-2012, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 1999-2016, International Business Machines Corporation and +others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2001-2016, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2002-2005, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2002-2006, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2002-2012, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2002-2014, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2003-2010 International Business Machines +Corporation and others. All Rights Reserved. + + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2004-2010, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2007-2012, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2007-2013, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2007-2014, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2007-2016, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2008-2010, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) 2008-2011, International Business Machines Corporation and +others. All Rights Reserved. + +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) IBM Corporation, 2000-2010. All rights reserved. + +This software is made available under the terms of the +ICU License -- ICU 1.8.1 and later. +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) IBM Corporation, 2000-2011. All rights reserved. + +This software is made available under the terms of the +ICU License -- ICU 1.8.1 and later. +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) IBM Corporation, 2000-2012. All rights reserved. + +This software is made available under the terms of the +ICU License -- ICU 1.8.1 and later. +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) IBM Corporation, 2000-2014. All rights reserved. + +This software is made available under the terms of the +ICU License -- ICU 1.8.1 and later. +-------------------------------------------------------------------------------- +icu + +© 2016 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html +Copyright (c) IBM Corporation, 2000-2016. All rights reserved. + +This software is made available under the terms of the +ICU License -- ICU 1.8.1 and later. +-------------------------------------------------------------------------------- +icu + +© 2017 and later: Unicode, Inc. and others. +License & terms of use: http://www.unicode.org/copyright.html + +Copyright (C) 2009-2017, International Business Machines Corporation, +Google, and others. All Rights Reserved. + + diff --git a/third_party/licenses/dart/source_span.txt b/third_party/licenses/dart/source_span.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/source_span.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/stack_trace.txt b/third_party/licenses/dart/stack_trace.txt new file mode 100644 index 0000000..162572a --- /dev/null +++ b/third_party/licenses/dart/stack_trace.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/stream_channel.txt b/third_party/licenses/dart/stream_channel.txt new file mode 100644 index 0000000..dbd2843 --- /dev/null +++ b/third_party/licenses/dart/stream_channel.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/string_scanner.txt b/third_party/licenses/dart/string_scanner.txt new file mode 100644 index 0000000..000cd7b --- /dev/null +++ b/third_party/licenses/dart/string_scanner.txt @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/synchronized.txt b/third_party/licenses/dart/synchronized.txt new file mode 100644 index 0000000..cf8a000 --- /dev/null +++ b/third_party/licenses/dart/synchronized.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016, Alexandre Roux Tekartik. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/term_glyph.txt b/third_party/licenses/dart/term_glyph.txt new file mode 100644 index 0000000..03af64a --- /dev/null +++ b/third_party/licenses/dart/term_glyph.txt @@ -0,0 +1,27 @@ +Copyright 2017, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/test_api.txt b/third_party/licenses/dart/test_api.txt new file mode 100644 index 0000000..9972f6e --- /dev/null +++ b/third_party/licenses/dart/test_api.txt @@ -0,0 +1,27 @@ +Copyright 2018, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/typed_data.txt b/third_party/licenses/dart/typed_data.txt new file mode 100644 index 0000000..dbd2843 --- /dev/null +++ b/third_party/licenses/dart/typed_data.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/universal_platform.txt b/third_party/licenses/dart/universal_platform.txt new file mode 100644 index 0000000..1d4f7b2 --- /dev/null +++ b/third_party/licenses/dart/universal_platform.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2019 gskinner.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/uri_parser.txt b/third_party/licenses/dart/uri_parser.txt new file mode 100644 index 0000000..c4c95d9 --- /dev/null +++ b/third_party/licenses/dart/uri_parser.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 & onwards Hitesh Kumar Saini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/url_launcher.txt b/third_party/licenses/dart/url_launcher.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_android.txt b/third_party/licenses/dart/url_launcher_android.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher_android.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_ios.txt b/third_party/licenses/dart/url_launcher_ios.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher_ios.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_linux.txt b/third_party/licenses/dart/url_launcher_linux.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher_linux.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_macos.txt b/third_party/licenses/dart/url_launcher_macos.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher_macos.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_platform_interface.txt b/third_party/licenses/dart/url_launcher_platform_interface.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/url_launcher_platform_interface.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_web.txt b/third_party/licenses/dart/url_launcher_web.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher_web.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/url_launcher_windows.txt b/third_party/licenses/dart/url_launcher_windows.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/url_launcher_windows.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/uuid.txt b/third_party/licenses/dart/uuid.txt new file mode 100644 index 0000000..0acaa79 --- /dev/null +++ b/third_party/licenses/dart/uuid.txt @@ -0,0 +1,7 @@ +Copyright (c) 2021 Yulian Kuncheff + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/dart/vector_math.txt b/third_party/licenses/dart/vector_math.txt new file mode 100644 index 0000000..29b709d --- /dev/null +++ b/third_party/licenses/dart/vector_math.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/vm_service.txt b/third_party/licenses/dart/vm_service.txt new file mode 100644 index 0000000..633672a --- /dev/null +++ b/third_party/licenses/dart/vm_service.txt @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/wakelock_plus.txt b/third_party/licenses/dart/wakelock_plus.txt new file mode 100644 index 0000000..840ee46 --- /dev/null +++ b/third_party/licenses/dart/wakelock_plus.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020-2023, creativecreatorormaybenot +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/third_party/licenses/dart/wakelock_plus_platform_interface.txt b/third_party/licenses/dart/wakelock_plus_platform_interface.txt new file mode 100644 index 0000000..5082ebe --- /dev/null +++ b/third_party/licenses/dart/wakelock_plus_platform_interface.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020-2023, creativecreatorormaybenot +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/web.txt b/third_party/licenses/dart/web.txt new file mode 100644 index 0000000..32e050b --- /dev/null +++ b/third_party/licenses/dart/web.txt @@ -0,0 +1,26 @@ +Copyright 2023, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/win32.txt b/third_party/licenses/dart/win32.txt new file mode 100644 index 0000000..b702dca --- /dev/null +++ b/third_party/licenses/dart/win32.txt @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Halil Durmus + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/windows_file_picker.txt b/third_party/licenses/dart/windows_file_picker.txt new file mode 100644 index 0000000..feb644f --- /dev/null +++ b/third_party/licenses/dart/windows_file_picker.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Miguel Ruivo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/dart/xdg_directories.txt b/third_party/licenses/dart/xdg_directories.txt new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/third_party/licenses/dart/xdg_directories.txt @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/dart/xml.txt b/third_party/licenses/dart/xml.txt new file mode 100644 index 0000000..8007d1d --- /dev/null +++ b/third_party/licenses/dart/xml.txt @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2006-2026 Lukas Renggli. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/third_party/licenses/dart/yaml.txt b/third_party/licenses/dart/yaml.txt new file mode 100644 index 0000000..e7589cb --- /dev/null +++ b/third_party/licenses/dart/yaml.txt @@ -0,0 +1,20 @@ +Copyright (c) 2014, the Dart project authors. +Copyright (c) 2006, Kirill Simonov. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/material-icons.txt b/third_party/licenses/material-icons.txt new file mode 100644 index 0000000..a2c590d --- /dev/null +++ b/third_party/licenses/material-icons.txt @@ -0,0 +1,393 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public licenses. +Notwithstanding, Creative Commons may elect to apply one of its public +licenses to material it publishes and in those instances will be +considered the "Licensor." Except for the limited purpose of indicating +that material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the public +licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/third_party/licenses/roboto-condensed.txt b/third_party/licenses/roboto-condensed.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/third_party/licenses/roboto-condensed.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/licenses/roboto.txt b/third_party/licenses/roboto.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/third_party/licenses/roboto.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tool/android/DownloadNetworkPolicyProbe.java b/tool/android/DownloadNetworkPolicyProbe.java new file mode 100644 index 0000000..bf4e13a --- /dev/null +++ b/tool/android/DownloadNetworkPolicyProbe.java @@ -0,0 +1,72 @@ +package com.bbflight.background_downloader; + +import android.app.job.JobInfo; +import android.content.ComponentName; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; + +/** Runs against the real Android 9 framework classes, not stubbed builders. */ +public final class DownloadNetworkPolicyProbe { + private static int checks; + private static void check(boolean value, String message) { + checks++; + if (!value) throw new AssertionError(message); + } + + public static void main(String[] args) { + // Android's getuid JNI library is not loaded by a plain host JVM. + // Fixture only the process identity; capability matching and JobInfo + // below remain the real unmodified Android framework implementation. + libcore.io.Os originalOs = libcore.io.Libcore.os; + libcore.io.Libcore.os = (libcore.io.Os) java.lang.reflect.Proxy.newProxyInstance( + libcore.io.Os.class.getClassLoader(), new Class[]{libcore.io.Os.class}, + (proxy, method, arguments) -> method.getName().equals("getuid") + ? 10000 : method.invoke(originalOs, arguments)); + NetworkCapabilities cellular = new NetworkCapabilities() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR); + cellular.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); + cellular.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); + NetworkCapabilities wifi = new NetworkCapabilities(cellular) + .removeTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI); + ComponentName service = new ComponentName("org.reelbox.app", "DownloadTestService"); + + // Reproduce why NETWORK_TYPE_ANY queues despite a reachable media host. + JobInfo legacy = new JobInfo.Builder(1, service) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY).build(); + check(legacy.getRequiredNetwork().hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + "Legacy ANY must reproduce the hidden validation requirement"); + check(!legacy.getRequiredNetwork().networkCapabilities.satisfiedByNetworkCapabilities(cellular), + "Legacy request should reject an unvalidated internet-capable network"); + + NetworkRequest any = DownloadNetworkPolicy.request(false); + NetworkRequest wifiOnly = DownloadNetworkPolicy.request(true); + check(any.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), "Internet is still required"); + check(!any.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), "No external validation requirement"); + check(!any.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN), "VPNs remain allowed"); + check(any.networkCapabilities.satisfiedByNetworkCapabilities(cellular), "Unvalidated cellular/VPN allowed"); + check(any.networkCapabilities.satisfiedByNetworkCapabilities(wifi), "Unvalidated Wi-Fi/VPN allowed"); + check(wifiOnly.hasTransport(NetworkCapabilities.TRANSPORT_WIFI), "Preserve Wi-Fi-only preference"); + check(!wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(cellular), "Wi-Fi-only rejects cellular"); + check(wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(wifi), "Wi-Fi-only accepts unvalidated Wi-Fi/VPN"); + check(!any.networkCapabilities.satisfiedByNetworkCapabilities(new NetworkCapabilities()), "No-internet network rejected"); + NetworkCapabilities vpnCellular = new NetworkCapabilities(cellular) + .addTransportType(NetworkCapabilities.TRANSPORT_VPN); + NetworkCapabilities vpnWifi = new NetworkCapabilities(wifi) + .addTransportType(NetworkCapabilities.TRANSPORT_VPN); + check(any.networkCapabilities.satisfiedByNetworkCapabilities(vpnCellular), "Explicit cellular-backed VPN allowed"); + check(!wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(vpnCellular), "Wi-Fi-only rejects cellular-backed VPN"); + check(wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities(vpnWifi), "Explicit Wi-Fi-backed VPN allowed"); + check(!wifiOnly.networkCapabilities.satisfiedByNetworkCapabilities( + new NetworkCapabilities(wifi).removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)), + "Wi-Fi without internet capability rejected"); + + JobInfo custom = new JobInfo.Builder(2, service).setRequiredNetwork(any).build(); + check(!custom.getRequiredNetwork().hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + "JobScheduler must not re-add validation to an explicit request"); + check(custom.getRequiredNetwork().networkCapabilities.satisfiedByNetworkCapabilities(cellular), + "UIDT-style explicit request can use the unvalidated network"); + System.out.println("PASS DownloadNetworkPolicy: " + checks + " real Android framework assertions"); + } +} diff --git a/tool/api_smoke.dart b/tool/api_smoke.dart new file mode 100644 index 0000000..b167c50 --- /dev/null +++ b/tool/api_smoke.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:reelbox/data/api_service.dart'; + +Future main() async { + final api = ApiService(); + await api.initialize(); + final home = await api.home(); + final movies = await api.catalog(); + final series = await api.catalog(kind: 'serie'); + if (movies.isEmpty || series.isEmpty) { + throw StateError('Live catalog is empty'); + } + // Catalogs can lead with announcements or titles whose sources are pending. + // Still require a real playable-source response, without assuming row zero. + var detail = await api.poster(movies.first.id); + final checked = >[ + {'id': detail.id, 'source_count': detail.sources.length}, + ]; + for (final candidate in movies.skip(1).take(4)) { + if (detail.sources.isNotEmpty) break; + detail = await api.poster(candidate.id); + checked.add({'id': detail.id, 'source_count': detail.sources.length}); + } + final seasons = await api.seasons(series.first.id); + final search = await api.search('Matrix', english: true); + final recommendations = await api.bestRandom(); + if (detail.sources.isEmpty || search.isEmpty || recommendations.isEmpty) { + throw StateError('A required public endpoint did not return expected data'); + } + stdout.writeln( + jsonEncode({ + 'api_base': api.baseUrl, + 'home_sections': (home['genres'] as List?)?.length, + 'movie_page_count': movies.length, + 'series_page_count': series.length, + 'movie_detail_id': detail.id, + 'movie_details_checked': checked, + 'movie_source_count': detail.sources.length, + 'series_detail_id': series.first.id, + 'season_count': seasons.length, + 'episode_source_count': seasons.fold( + 0, + (sum, s) => + sum + s.episodes.fold(0, (n, e) => n + e.sources.length), + ), + 'english_search_results': search.length, + 'recommendations': recommendations.length, + 'account_used': false, + 'media_downloaded': false, + }), + ); +} diff --git a/tool/api_smoke_test.dart b/tool/api_smoke_test.dart new file mode 100644 index 0000000..0dc8905 --- /dev/null +++ b/tool/api_smoke_test.dart @@ -0,0 +1,25 @@ +// Explicit opt-in live read-only probe, outside default test discovery. +// Only local preference storage is a fixture; API responses are real network data. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_smoke.dart' as probe; + +// Use the normal Dart HTTP client instead of Flutter test's deliberate HTTP-400 +// client. This does not change TLS validation, credentials or access denials. +class _LiveReadOnlyNetwork extends HttpOverrides {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('live read-only catalog contracts and source availability', () async { + // Explicit test-only harness outside automatic test discovery. + // ignore: invalid_use_of_visible_for_testing_member + SharedPreferences.setMockInitialValues({}); + await HttpOverrides.runWithHttpOverrides( + probe.main, + _LiveReadOnlyNetwork(), + ); + }, timeout: const Timeout(Duration(minutes: 3))); +} diff --git a/tool/banner_ui_capture_test.dart b/tool/banner_ui_capture_test.dart new file mode 100644 index 0000000..007e368 --- /dev/null +++ b/tool/banner_ui_capture_test.dart @@ -0,0 +1,119 @@ +// Explicit host-rendered UI evidence; not Android-device verification. +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/catalog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test/home_banner_widget_test.dart' show HomeBannerFixtureApi; + +void main() { + testWidgets( + 'actual home Telegram at 390x844 in English/Persian including API error and tap', + (tester) async { + await tester.runAsync(() async { + final bytes = await File( + Platform.environment['REELBOX_TEST_FONT'] ?? + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + ).readAsBytes(); + for (final family in ['Roboto', 'Ahem']) { + await (FontLoader( + family, + )..addFont(Future.value(bytes.buffer.asByteData()))).load(); + } + final icons = await File( + Platform.environment['REELBOX_ICON_FONT'] ?? '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf', + ).readAsBytes(); + await (FontLoader( + 'MaterialIcons', + )..addFont(Future.value(icons.buffer.asByteData()))).load(); + }); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final output = Directory( + Platform.environment['REELBOX_SCREENSHOT_DIR'] ?? + '${Directory.current.path}/build/banner-ui-verification', + ); + await tester.runAsync(() => output.create(recursive: true)); + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + final targets = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + targets.add(call.arguments['url'] as String); + return true; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + for (final failed in [false, true]) { + for (final locale in ['en', 'fa']) { + // ignore: invalid_use_of_visible_for_testing_member + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: HomeBannerFixtureApi()..fail = failed, + storage: await SharedPreferences.getInstance(), + ); + await state.initialize(discover: false); + await state.updateSettings( + state.prefs.copyWith(locale: locale, introCompleted: true), + ); + final boundaryKey = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: MyApp(state: state), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(DiscoverPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + final title = locale == 'en' + ? 'ReelBox updates' + : 'تازه‌های ریل‌باکس'; + expect(find.text(title), findsOneWidget); + expect( + tester + .getRect(find.byKey(const Key('home-banner-telegram'))) + .bottom, + lessThan(844), + ); + if (failed) expect(find.text('Offline'), findsWidgets); + expect(tester.takeException(), isNull); + final boundary = + boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final name = 'home-banner-$locale${failed ? '-error' : ''}'; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + expect(image.width, 390); + expect(image.height, 844); + final bytes = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + await File('${output.path}/$name.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + await tester.tap(find.byKey(const Key('home-banner-telegram'))); + await tester.pumpAndSettle(); + expect(targets.last, 'https://t.me/ReelBoxApp'); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + } + } + expect(targets, List.filled(4, 'https://t.me/ReelBoxApp')); + }, + ); +} diff --git a/tool/catalogue_deployment_smoke.py b/tool/catalogue_deployment_smoke.py new file mode 100644 index 0000000..224efd8 --- /dev/null +++ b/tool/catalogue_deployment_smoke.py @@ -0,0 +1,85 @@ +"""Read-only deployed API checks; never requests any returned media URL.""" +import argparse +import hashlib +import json +from pathlib import Path +import sqlite3 +import time +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--origin', default='https://reelbox.dartty.top:8443') + parser.add_argument('--database', default='/root/moviebox-export/catalog.sqlite') + parser.add_argument('--output', required=True) + args = parser.parse_args() + db = sqlite3.connect(Path(args.database).resolve().as_uri() + '?mode=ro', uri=True) + timings = {} + + def get(path): + start = time.monotonic() + with urlopen(args.origin + path, timeout=40) as response: + assert response.status == 200 + assert 'application/json' in response.headers['Content-Type'] + value = json.load(response) + timings[path.split('?')[0]] = round(time.monotonic() - start, 3) + return value + + health = get('/api/health') + assert health['status'] == 'ok' and health['media_proxy'] is False and health['offline'] is True + for table in ('titles', 'episodes', 'seasons', 'movie_sources', 'episode_sources'): + assert health['counts'][table] == db.execute('SELECT count(*) FROM ' + table).fetchone()[0] + home = get('/api/first/public/') + assert home['genres'][0]['posters'] + movies = get('/api/movie/by/filtres/0/created/0/public/') + series = get('/api/serie/by/filtres/0/created/0/public/') + assert movies and series + movie = next((get('/api/movie/by/' + str(row['id']) + '/public/') for row in movies + if db.execute('SELECT 1 FROM movie_sources WHERE title_id=? LIMIT 1', (row['id'],)).fetchone()), None) + assert movie and movie['sources'] + expected = [json.loads(r[0]) for r in db.execute('SELECT raw_json FROM movie_sources WHERE title_id=? ORDER BY source_index', (movie['id'],))] + assert movie['sources'] == expected, 'Movie source records changed' + seasons = get('/api/season/by/serie/' + str(series[0]['id']) + '/public/') + expected_seasons = [json.loads(r[0]) for r in db.execute('SELECT raw_json FROM seasons WHERE series_id=? ORDER BY season_index', (series[0]['id'],))] + actual_urls = [source['url'] for season in seasons for ep in season.get('episodes', []) for source in ep.get('sources', [])] + expected_urls = [source['url'] for season in expected_seasons for ep in season.get('episodes', []) for source in ep.get('sources', [])] + assert actual_urls and actual_urls == expected_urls, 'Episode source URLs changed' + for url in actual_urls + [source['url'] for source in movie['sources']]: + from urllib.parse import urlsplit + assert urlsplit(url).hostname not in {'reelbox.dartty.top', '87.107.110.187'} + search = get('/api-user/newapi/like.php?action=search-movie-en&q=Matrix&pageno=1') + assert search + assert get('/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1') + assert get('/api/genre/all/public/') and get('/api/country/all/public/') + assert get('/api-user/newapi/pishnahad.php') + rejected = {} + for method, path in [('GET', '/api/proxy?url=https://media.example/movie.mkv'), + ('GET', '/api/movie.mkv'), ('GET', '/api/comments/by/poster/1/public/'), + ('GET', '/api-user/newapi/like.php?action=search-movie&url=http://127.0.0.1/'), + ('GET', '/catalog.sqlite'), ('POST', '/api/health')]: + try: + with urlopen(Request(args.origin + path, method=method), timeout=15) as response: + raise AssertionError('Unexpected accepted request: ' + path + ' status=' + str(response.status)) + except HTTPError as error: + assert error.code in {400, 403, 404, 405} + rejected[method + ' ' + path] = error.code + error.close() + with urlopen(args.origin + '/', timeout=15) as response: + assert response.status == 200 and b'Reelbox' in response.read() + report = {'passed': True, 'origin': args.origin, 'health': health, + 'movie_id': movie['id'], 'series_id': series[0]['id'], + 'movie_source_records_match_snapshot': len(expected), + 'episode_source_urls_match_snapshot': len(actual_urls), + 'search_matches': len(search), 'rejected_routes': rejected, + 'website_still_available': True, 'media_downloaded': False, 'seconds_by_route': timings} + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + db.close() + + +if __name__ == '__main__': + main() diff --git a/tool/download_network_policy_test.py b/tool/download_network_policy_test.py new file mode 100644 index 0000000..9cec7d0 --- /dev/null +++ b/tool/download_network_policy_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Exercise download network requests with real Android 9 framework classes. + +This verifies capability matching and JobInfo construction, not a device, +WorkManager execution, internet connectivity, or OEM background scheduling. +""" +from pathlib import Path +import fcntl +import hashlib +import json +import os +import subprocess +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +WORK = Path(os.environ.get('TMPDIR', '/root/.hermes/cache/scratch')) / 'reelbox-network-test' +FRAMEWORK_URL = 'https://repo.maven.apache.org/maven2/org/robolectric/android-all/9-robolectric-4913185-2/android-all-9-robolectric-4913185-2.jar' +FRAMEWORK_SHA256 = 'cf4aed5e458deb48ddc0ca92fc25156e6f478d9d5c1d948cdd1817a67ee11b32' + + +def main(): + WORK.mkdir(parents=True, exist_ok=True) + framework = WORK / 'android-all.jar' + if not framework.exists() or hashlib.sha256(framework.read_bytes()).hexdigest() != FRAMEWORK_SHA256: + with urllib.request.urlopen(FRAMEWORK_URL, timeout=90) as response: + blob = response.read() + assert hashlib.sha256(blob).hexdigest() == FRAMEWORK_SHA256, 'Framework checksum mismatch' + framework.write_bytes(blob) + classes = WORK / 'classes' + classes.mkdir(exist_ok=True) + source = ROOT / 'third_party/background_downloader/android/src/main/kotlin/com/bbflight/background_downloader/DownloadNetworkPolicy.java' + test = ROOT / 'tool/android/DownloadNetworkPolicyProbe.java' + with (ROOT / '.tooling.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + (WORK / 'verification.json').unlink(missing_ok=True) + command = ['javac', '-J-Xmx128m', '--release', '17', '-cp', str(framework), '-d', str(classes), str(test)] + if source.exists(): + command.append(str(source)) + subprocess.run(command, check=True, timeout=90) + result = subprocess.run(['java', '-Xmx192m', '-cp', os.pathsep.join([str(classes), str(framework)]), + 'com.bbflight.background_downloader.DownloadNetworkPolicyProbe'], + check=False, capture_output=True, text=True, timeout=90) + print(result.stdout, end='') + if result.returncode: + print(result.stderr, end='') + raise subprocess.CalledProcessError(result.returncode, result.args) + assert 'PASS DownloadNetworkPolicy:' in result.stdout + (WORK / 'verification.json').write_text(json.dumps({ + 'passed': True, + 'framework_sha256': FRAMEWORK_SHA256, + 'stdout': result.stdout, + 'scope': 'real Android 9 framework capability matching and JobInfo construction; no device or WorkManager execution', + 'android_hardware_verified': False, + }, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/tool/download_ui_capture_test.dart b/tool/download_ui_capture_test.dart new file mode 100644 index 0000000..04366f0 --- /dev/null +++ b/tool/download_ui_capture_test.dart @@ -0,0 +1,162 @@ +// Explicit host-rendered component evidence, not Android-device verification. +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/media/download_button.dart'; +import 'package:reelbox/media/download_service.dart'; +import 'package:reelbox/media/downloads_page.dart'; +import 'package:reelbox/state/app_state.dart'; + +import '../test/catalog_widget_test.dart' show CatalogApi, mountCatalog; + +void main() { + testWidgets('download components at 390px in English and Persian', ( + tester, + ) async { + await tester.runAsync(() async { + final font = await File('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf') + .readAsBytes(); + for (final family in ['Roboto', 'Ahem']) { + await (FontLoader( + family, + )..addFont(Future.value(font.buffer.asByteData()))).load(); + } + final icons = await File( + '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf', + ).readAsBytes(); + await (FontLoader( + 'MaterialIcons', + )..addFont(Future.value(icons.buffer.asByteData()))).load(); + }); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final state = await mountCatalog(tester, CatalogApi(), const Scaffold()); + addTearDown(state.dispose); + final boundaryKey = GlobalKey(); + final paused = DownloadEntry.fromTask( + DownloadTask(url: 'https://media.example/movie.mp4'), + taskStatus: TaskStatus.paused, + progress: .25, + totalBytes: 104857600, + ); + final failed = DownloadEntry.fromTask( + DownloadTask(url: 'https://media.example/expired.mp4'), + taskStatus: TaskStatus.failed, + error: 'HTTP 403: this media URL has expired.', + ); + for (final locale in ['en', 'fa']) { + await state.updateSettings(state.prefs.copyWith(locale: locale)); + await tester.pumpWidget( + AppScope( + state: state, + child: MaterialApp( + theme: ThemeData.dark(useMaterial3: true).copyWith( + appBarTheme: const AppBarTheme( + titleTextStyle: TextStyle(fontFamily: 'Roboto', fontSize: 20), + ), + ), + home: RepaintBoundary( + key: boundaryKey, + child: Directionality( + textDirection: locale == 'fa' + ? TextDirection.rtl + : TextDirection.ltr, + child: Builder( + builder: (context) => Scaffold( + appBar: AppBar( + title: Text(tr(context, 'Downloads', 'دانلودها')), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + tr(context, 'Source · 720p', 'منبع · 720p'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + DownloadButton( + title: const TitleRecord( + id: 7, + title: 'Fixture movie', + ), + source: const MediaSource( + quality: '720p', + url: 'https://media.example/movie.mp4', + ), + enqueue: ({ + required title, + required source, + episode, + }) async {}, + ), + const SizedBox(height: 18), + for (final entry in [paused, failed]) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr( + context, + 'Fixture movie · 720p', + 'فیلم نمونه · 720p', + ), + style: Theme.of(context) + .textTheme + .titleMedium, + ), + const SizedBox(height: 12), + DownloadTransferDetails(entry: entry), + const SizedBox(height: 8), + DownloadControls( + entry: entry, + onPause: () async {}, + onResume: () async {}, + onRetry: () async {}, + onCancel: () async {}, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(DownloadButton), findsOneWidget); + expect(find.byType(DownloadTransferDetails), findsNWidgets(2)); + expect(tester.takeException(), isNull); + final boundary = + boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + await tester.runAsync(() async { + final directory = Directory( + '/root/reelbox/build/download-ui-verification', + ); + await directory.create(recursive: true); + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await File('${directory.path}/downloads-$locale.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + } + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/tool/iran_api_smoke_test.dart b/tool/iran_api_smoke_test.dart new file mode 100644 index 0000000..2884305 --- /dev/null +++ b/tool/iran_api_smoke_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _LiveNetwork extends HttpOverrides {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'deployed Iran Catalogue works through the real app API client', + () async { + // Explicit opt-in live test; only local preferences are mocked. + // ignore: invalid_use_of_visible_for_testing_member + SharedPreferences.setMockInitialValues({}); + await HttpOverrides.runWithHttpOverrides(() async { + final api = ApiService(); + await api.setBaseUrl('https://reelbox.dartty.top/api/'); + await api.initialize(); + expect(api.baseUrl, 'https://reelbox.dartty.top/api/'); + final home = await api.home(); + final genres = await api.genres(); + final countries = await api.countries(); + final movies = await api.catalog(); + final series = await api.catalog(kind: 'serie'); + expect(home['genres'], isNotEmpty); + expect(genres, isNotEmpty); + expect(countries, isNotEmpty); + expect(movies, isNotEmpty); + expect(series, isNotEmpty); + TitleRecord? film; + for (final candidate in movies.take(5)) { + final detail = await api.poster(candidate.id); + if (detail.sources.isNotEmpty) { + film = detail; + break; + } + } + expect(film, isNotNull); + final seasons = await api.seasons(series.first.id); + expect(seasons, isNotEmpty); + final episodeSources = seasons + .expand((s) => s.episodes) + .expand((e) => e.sources); + expect(episodeSources, isNotEmpty); + final allSources = [...film!.sources, ...episodeSources]; + for (final source in allSources) { + final uri = Uri.parse(source.url); + expect(uri.scheme, anyOf('http', 'https')); + expect(uri.host, isNot('reelbox.dartty.top')); + expect(uri.host, isNot('87.107.110.187')); + } + final search = await api.search('Matrix', english: true); + final recommendations = await api.bestRandom(); + final curated = await api.curatedLinks(); + expect(search, isNotEmpty); + expect(recommendations, isNotEmpty); + if (curated.isNotEmpty) { + expect( + await api.curatedContent(curated.first['link'] as String), + isNotEmpty, + ); + } + expect(await api.comments(film.id), isEmpty); + final restored = ApiService(); + await restored.initialize(discover: false); + expect(restored.baseUrl, api.baseUrl); + stdout.writeln( + jsonEncode({ + 'api_base': api.baseUrl, + 'genre_count': genres.length, + 'country_count': countries.length, + 'movie_page_count': movies.length, + 'series_page_count': series.length, + 'movie_id': film.id, + 'movie_sources': film.sources.length, + 'series_id': series.first.id, + 'season_count': seasons.length, + 'episode_sources': episodeSources.length, + 'search_count': search.length, + 'recommendations': recommendations.length, + 'curated_groups': curated.length, + 'all_media_sources_direct': true, + 'api_choice_restored': true, + 'media_downloaded': false, + }), + ); + }, _LiveNetwork()); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/tool/native_player_timing_test.py b/tool/native_player_timing_test.py new file mode 100644 index 0000000..bc04be7 --- /dev/null +++ b/tool/native_player_timing_test.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Run real Android activity timing/EOF handlers with fixture events, without JNI. + +Uses the existing lean Robolectric resolver/runner. This is control-flow evidence, +not decoded-media, network, or physical-device verification. +""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import sys +import zipfile + +import player_overlay_android_test as harness + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox' +WORK = Path('/root/.hermes/cache/scratch/reelbox-native-timing') + + +def aar_classes(artifact, version): + matches = sorted((harness.CACHE / artifact / version).glob('*/*.aar')) + if not matches: + raise RuntimeError(f'Missing cached AAR: {artifact}') + target = WORK / (matches[-1].stem + '-classes.jar') + with zipfile.ZipFile(matches[-1]) as archive: + target.write_bytes(archive.read('classes.jar')) + return target + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--test') + args = parser.parse_args() + WORK.mkdir(parents=True, exist_ok=True) + (WORK / 'tmp').mkdir(exist_ok=True) + harness.WORK = WORK + sdk = Path(os.environ.get('ANDROID_HOME', os.environ.get('ANDROID_SDK_ROOT', str(Path.home() / 'Android/Sdk')))) + android = sdk / 'platforms/android-36/android.jar' + if not android.is_file(): + raise RuntimeError(f'Android 36 jar missing: {android}') + with (ROOT / '.tooling.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + harness.LOCK_FD = lock.fileno() + deps = harness.resolve() + stdlib = harness.cached_jar('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0') + vlc = aar_classes('org.videolan.android/libvlc-all', '3.7.7') + core = aar_classes('androidx.core/core', '1.16.0') + classes = WORK / 'classes' + classes.mkdir(exist_ok=True) + cp = os.pathsep.join(map(str, [classes, android, stdlib, vlc, core, *deps])) + compiler = [harness.cached_jar('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'), stdlib, + harness.cached_jar('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'), + harness.cached_jar('org.jetbrains.kotlin/kotlin-reflect'), + harness.cached_jar('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm'), + harness.cached_jar('org.jetbrains/annotations')] + compiler += sorted((harness.CACHE / 'org.jetbrains.intellij.deps/trove4j').glob('*/*/*.jar'))[-1:] + sources = [SRC / name for name in ('NativeUrlPolicy.kt', 'NativePlayerPolicy.kt', 'NativeFileIntents.kt', + 'PlayerControlsState.kt', 'PlayerOverlayView.kt', 'VlcPlayerActivity.kt')] + test = ROOT / 'android/app/src/test/java/org/reelbox/reelbox/NativePlayerTimingAndroidTest.java' + hashes = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in [*sources, test]} + (WORK / 'tested-inputs.json').write_text(json.dumps(hashes, indent=2) + '\n') + harness.run([str(harness.JDK / 'bin/java'), '-Xmx256m', '-XX:+UseSerialGC', f'-Djava.io.tmpdir={WORK / "tmp"}', + '-cp', os.pathsep.join(map(str, compiler)), 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', + '-jvm-target', '17', '-no-stdlib', '-no-reflect', '-classpath', cp, + '-d', str(classes), *map(str, sources)], log='kotlin-compile.log') + (WORK / 'OverlayHostRunner.java').write_text(harness.RUNNER) + (WORK / 'OverlayHarnessSmokeTest.java').write_text(harness.SMOKE) + harness.run([str(harness.JDK / 'bin/javac'), '-J-Xmx192m', '--release', '17', '-cp', cp, '-d', str(classes), + str(WORK / 'OverlayHostRunner.java'), str(WORK / 'OverlayHarnessSmokeTest.java'), str(test)], log='javac-timing.log') + opens = ['java.base/java.lang', 'java.base/java.util', 'java.base/java.io', 'java.base/java.net', + 'java.base/java.security', 'java.base/java.text', 'java.base/jdk.internal.access', + 'java.desktop/java.awt.font', 'jdk.compiler/com.sun.tools.javac.api'] + java = [str(harness.JDK / 'bin/java'), '-Xmx320m', '-XX:MaxMetaspaceSize=192m', '-XX:+UseSerialGC', + f'-Djava.io.tmpdir={WORK / "tmp"}', '-Drobolectric.dependency.repo.url=https://repo.maven.apache.org/maven2', + f'-Dmaven.repo.local={WORK / "m2"}', '-Drobolectric.graphicsMode=LEGACY', + *['--add-opens=' + p + '=ALL-UNNAMED' for p in opens], '-cp', cp, + 'org.reelbox.reelbox.OverlayHostRunner'] + harness.run([*java, 'org.reelbox.reelbox.OverlayHarnessSmokeTest', str(WORK / 'smoke-results.xml')], log='smoke.log') + command = [*java, 'org.reelbox.reelbox.NativePlayerTimingAndroidTest', str(WORK / 'timing-results.xml')] + if args.test: + command.append(args.test) + harness.run(command, log='timing.log') + assert all(hashlib.sha256(Path(p).read_bytes()).hexdigest() == digest for p, digest in hashes.items()) + print('PASS: actual activity event handlers and result gate; fixture events, no decoder/device claims') + + +if __name__ == '__main__': + main() diff --git a/tool/native_resource_fetch_test.py b/tool/native_resource_fetch_test.py new file mode 100644 index 0000000..85e282b --- /dev/null +++ b/tool/native_resource_fetch_test.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Offline, serialized JVM regressions; uses the existing Gradle Kotlin cache.""" +import argparse +import fcntl +import glob +import os +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox' +TEST = ROOT / 'android/app/src/test/kotlin/org/reelbox/reelbox' + + +def jar(group, artifact, version): + matches = glob.glob(str(Path.home() / '.gradle/caches/modules-2/files-2.1' / + group / artifact / version / '*' / '*.jar')) + if len(matches) != 1: + raise RuntimeError(f'Expected one cached {artifact}:{version}, got {len(matches)}') + return matches[0] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--case', default='all') + parser.add_argument('--skip-integration', action='store_true') + args = parser.parse_args() + with (ROOT / '.tooling.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + helper = SRC / 'RestrictedResourceFetcher.kt' + assert helper.exists(), 'RM007: no controlled resource fetcher; WebView owns unchecked redirects' + version = '2.2.21' + stdlib = jar('org.jetbrains.kotlin', 'kotlin-stdlib', version) + annotations = jar('org.jetbrains', 'annotations', '13.0') + compiler = [jar('org.jetbrains.kotlin', item, version) for item in ( + 'kotlin-compiler-embeddable', 'kotlin-stdlib', 'kotlin-reflect', + 'kotlin-script-runtime', 'kotlin-daemon-embeddable')] + compiler += [annotations, jar('org.jetbrains.kotlinx', 'kotlinx-coroutines-core-jvm', '1.8.0')] + with tempfile.TemporaryDirectory(prefix='native-resource-test-', dir=os.environ.get('TMPDIR')) as out: + sources = [SRC / 'NativeUrlPolicy.kt', SRC / 'PlaybackSnapshot.kt', helper, + TEST / 'NativePolicyTest.kt', TEST / 'RestrictedResourceFetchTest.kt'] + subprocess.run(['java', '-Xmx256m', '-XX:MaxMetaspaceSize=192m', '-XX:+UseSerialGC', + '-cp', os.pathsep.join(compiler), 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', + '-no-stdlib', '-no-reflect', '-jvm-target', '17', + '-classpath', os.pathsep.join([stdlib, annotations]), '-d', out, + *map(str, sources)], check=True, cwd=ROOT, timeout=120) + classpath = os.pathsep.join([out, stdlib]) + subprocess.run(['java', '-Xmx128m', '-cp', classpath, + 'org.reelbox.reelbox.tests.NativePolicyTestKt'], check=True, cwd=ROOT, timeout=60) + subprocess.run(['java', '-Xmx128m', '--add-modules', 'jdk.httpserver', '-cp', classpath, + 'org.reelbox.reelbox.tests.RestrictedResourceFetchTestKt', args.case], check=True, cwd=ROOT, timeout=60) + if not args.skip_integration: + activity = (SRC / 'EmbeddedVideoActivity.kt').read_text() + start = activity.index('override fun shouldInterceptRequest(') + end = activity.index('override fun onReceivedSslError', start) + interceptor = activity[start:end] + assert 'return null' not in interceptor, 'RM007: interceptor delegates unchecked redirects to WebView' + assert 'resourceFetcher.fetch(' in interceptor, 'RM007: controlled fetcher is not wired to WebView' + assert interceptor.count('resourceFetcher.fetch(') == 2, 'Both WebView interceptor overloads must use controlled fetching' + assert 'request.method, request.requestHeaders' in interceptor, 'Method/range headers are not wired' + assert 'secureContext = !request.isForMainFrame && securePage' in interceptor, 'Mixed-content guard is missing' + assert 'handler.cancel()' in activity and 'handler.proceed()' not in activity, 'TLS errors must fail closed' + assert 'response.body.close()' in activity and 'catch (_: IllegalArgumentException)' in activity, 'Invalid Android response metadata must fail closed' + print('WebView interceptor source wiring: passed (not a device test)', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tool/player_controls_test.py b/tool/player_controls_test.py new file mode 100644 index 0000000..b838595 --- /dev/null +++ b/tool/player_controls_test.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Run deterministic real Kotlin controls tests and optional Android SDK compilation. + +Uses cached Kotlin jars, Android 36 and LibVLC 3.7.7; no downloads or Gradle. +All compiler invocations share the repository tooling lock. +""" +import argparse +import fcntl +import os +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox' +TEST = ROOT / 'android/app/src/test/kotlin/org/reelbox/reelbox' +CACHE = Path.home() / '.gradle/caches/modules-2/files-2.1' +SCRATCH = Path('/root/.hermes/cache/scratch') + + +def compiler_jars(): + jars = [] + for artifact, version in [ + ('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'), + ('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0'), + ('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'), + ('org.jetbrains.kotlin/kotlin-reflect', '*'), + ('org.jetbrains.intellij.deps/trove4j', '*'), + ('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm', '*'), + ('org.jetbrains/annotations', '*'), + ]: + matches = sorted((CACHE / artifact).glob(version + '/*/*.jar')) + if matches: + jars.append(str(matches[-1])) + elif version != '*': + raise RuntimeError(f'Missing cached compiler dependency: {artifact}:{version}') + return jars + + +def compile_sources(jars, sources, classpath, out): + subprocess.run([ + 'java', '-Xmx320m', '-XX:MaxMetaspaceSize=192m', '-XX:+UseSerialGC', + f'-Djava.io.tmpdir={SCRATCH}', '-cp', os.pathsep.join(jars), + 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', '-jvm-target', '17', + '-no-stdlib', '-no-reflect', '-classpath', os.pathsep.join(classpath), + '-d', str(out), *map(str, sources), + ], cwd=ROOT, check=True, timeout=180) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--native', action='store_true', help='Also compile actual native player sources') + args = parser.parse_args() + print('Waiting for repository tooling lock', flush=True) + with (ROOT / '.tooling.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + print('Repository tooling lock acquired', flush=True) + jars = compiler_jars() + with tempfile.TemporaryDirectory(prefix='player-controls-', dir=SCRATCH) as temp: + out = Path(temp) / 'state' + compile_sources(jars, [SRC / 'PlayerControlsState.kt', TEST / 'PlayerControlsStateTest.kt'], jars, out) + subprocess.run(['java', '-Xmx96m', '-cp', os.pathsep.join([str(out), *jars]), + 'org.reelbox.reelbox.tests.PlayerControlsStateTestKt'], + check=True, cwd=ROOT, timeout=30) + if args.native: + deps = [ + '/root/developer-tools/android/platforms/android-36/android.jar', + '/root/.hermes/cache/scratch/reelbox-vlc-api/classes.jar', + '/root/developer-tools/flutter/bin/cache/artifacts/engine/android-arm64-release/flutter.jar', + str(ROOT / 'build/app/tmp/kotlin-classes/release'), + ] + deps += map(str, (Path.home() / '.gradle/caches/9.3.1/transforms').glob('*/transformed/core-1.16.0-api.jar')) + deps += map(str, (CACHE / 'androidx.lifecycle/lifecycle-common-jvm').glob('*/*/*.jar')) + sources = [SRC / name for name in [ + 'NativeUrlPolicy.kt', 'NativePlayerPolicy.kt', 'NativeFileIntents.kt', + 'PlayerControlsState.kt', 'PlayerOverlayView.kt', 'VlcPlayerActivity.kt', 'MainActivity.kt', + ]] + compile_sources(jars, sources, [*jars, *deps], Path(temp) / 'native') + print('Native player compilation PASS: actual Android 36 / LibVLC 3.7.7 SDKs', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tool/player_overlay_android_test.py b/tool/player_overlay_android_test.py new file mode 100644 index 0000000..b11477a --- /dev/null +++ b/tool/player_overlay_android_test.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Lean host Android-view tests, without the Android/Flutter Gradle build. + +Run: python3 tool/player_overlay_android_test.py +--setup-only resolves reusable dependencies and exercises Android/Handler smoke tests. +--test METHOD runs one production-overlay method; all methods run by default. +All Gradle/compiler/JVM processes acquire the repository's .tooling.lock. +Outputs, dependency manifests and JUnit XML live in scratch, not the app build. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import shutil +import shlex +import subprocess +import sys +import time +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +WORK = Path('/root/.hermes/cache/scratch/reelbox-overlay-android-tests') +CACHE = Path.home() / '.gradle/caches/modules-2/files-2.1' +JDK = Path(os.environ.get('OVERLAY_TEST_JAVA_HOME', '/usr/lib/jvm/java-17-openjdk-amd64')) +ANDROID = Path(os.environ.get('OVERLAY_TEST_ANDROID_JAR', '/root/developer-tools/android/platforms/android-36/android.jar')) +LOCK = ROOT / '.tooling.lock' +LOCK_FD: int | None = None + +BUILD = ''' +import org.gradle.api.attributes.java.TargetJvmEnvironment +plugins { id 'base' } +repositories { + maven { url = uri('https://repo.maven.apache.org/maven2') } + google { content { includeGroupByRegex 'androidx[.].*' } } +} +configurations { + harness { + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME)) + attribute(TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, + objects.named(TargetJvmEnvironment, TargetJvmEnvironment.STANDARD_JVM)) + } + } +} +dependencies { + harness('org.robolectric:robolectric:4.16.1') { + // LEGACY graphics never loads the large native renderer distribution. + exclude group: 'org.robolectric', module: 'nativeruntime-dist-compat' + } + harness 'junit:junit:4.13.2' +} +tasks.register('resolveHarness') { + doLast { + file('classpath.txt').text = configurations.harness.files.collect { it.absolutePath }.sort().join('\\n') + '\\n' + } +} +''' + +SMOKE = ''' +package org.reelbox.reelbox; +import android.app.Activity; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.Button; +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.LooperMode; +import org.robolectric.annotation.GraphicsMode; +import org.robolectric.Shadows; +import static org.junit.Assert.*; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, manifest = Config.NONE, qualifiers = "w360dp-h780dp-mdpi") +@LooperMode(LooperMode.Mode.PAUSED) +@GraphicsMode(GraphicsMode.Mode.LEGACY) +public class OverlayHarnessSmokeTest { + @Test public void realAndroidLayoutClickAndMainHandler() { + try (var controller = Robolectric.buildActivity(Activity.class).setup().visible()) { + Activity activity = controller.get(); + FrameLayout root = new FrameLayout(activity); + Button button = new Button(activity); + root.addView(button, new FrameLayout.LayoutParams(120, 48)); + activity.setContentView(root); + root.measure(View.MeasureSpec.makeMeasureSpec(360, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(780, View.MeasureSpec.EXACTLY)); + root.layout(0, 0, 360, 780); + assertEquals(120, button.getWidth()); + assertEquals(48, button.getHeight()); + int[] clicks = {0}; + button.setOnClickListener(v -> clicks[0]++); + assertTrue(button.performClick()); + assertEquals(1, clicks[0]); + new Handler(Looper.getMainLooper()).postDelayed(() -> button.setVisibility(View.GONE), 3000); + Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2999)); + assertEquals(View.VISIBLE, button.getVisibility()); + Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1)); + assertEquals(View.GONE, button.getVisibility()); + } + } +} +''' + +RUNNER = ''' +package org.reelbox.reelbox; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.junit.runner.JUnitCore; +import org.junit.runner.Request; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; +public class OverlayHostRunner { + public static void main(String[] args) throws Exception { + JUnitCore core = new JUnitCore(); + core.addListener(new RunListener() { + @Override public void testStarted(org.junit.runner.Description d) { + System.out.println("RUN " + d.getMethodName()); + } + @Override public void testFailure(Failure f) { + System.err.println("FAIL " + f.getTestHeader() + "\\n" + f.getTrace()); + } + }); + Class clazz = Class.forName(args[0]); + Result result = core.run(args.length > 2 ? Request.method(clazz, args[2]) : Request.aClass(clazz)); + var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + var suite = doc.createElement("testsuite"); + suite.setAttribute("name", args[0]); + suite.setAttribute("tests", Integer.toString(result.getRunCount())); + suite.setAttribute("failures", Integer.toString(result.getFailureCount())); + suite.setAttribute("skipped", Integer.toString(result.getIgnoreCount())); + suite.setAttribute("time", Double.toString(result.getRunTime() / 1000.0)); + doc.appendChild(suite); + for (Failure failure : result.getFailures()) { + var test = doc.createElement("testcase"); + test.setAttribute("name", failure.getTestHeader()); + var fail = doc.createElement("failure"); + fail.setAttribute("message", String.valueOf(failure.getMessage())); + fail.setTextContent(failure.getTrace()); + test.appendChild(fail); suite.appendChild(test); + } + TransformerFactory.newInstance().newTransformer().transform(new DOMSource(doc), new StreamResult(new File(args[1]))); + System.out.printf("RESULT tests=%d failures=%d ignored=%d elapsed_ms=%d%n", result.getRunCount(), result.getFailureCount(), result.getIgnoreCount(), result.getRunTime()); + System.exit(result.wasSuccessful() && result.getRunCount() > 0 ? 0 : 1); + } +} +''' + + +def run(command: list[str], *, log: str | None = None) -> None: + env = dict(os.environ, JAVA_HOME=str(JDK), TMPDIR=str(WORK / 'tmp')) + env['PATH'] = str(JDK / 'bin') + os.pathsep + env.get('PATH', '') + display = command.copy() + for flag in ('-cp', '-classpath'): + if flag in display: + index = display.index(flag) + 1 + display[index] = f'<{len(display[index].split(os.pathsep))} classpath entries>' + print('+', shlex.join(display), flush=True) + assert LOCK_FD is not None, 'Heavy invocations must hold the tooling flock' + with (WORK / 'commands.log').open('a') as command_log: + command_log.write(shlex.join(command) + '\n') + # Inherit the locked descriptor so a timed-out parent cannot unlock a live JVM. + proc = subprocess.Popen(command, cwd=WORK, env=env, pass_fds=(LOCK_FD,), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + assert proc.stdout is not None + log_path = WORK / (log or 'last-command.log') + if log_path.exists(): + log_path.rename(log_path.with_name(log_path.name + '.' + str(time.time_ns()))) + with log_path.open('w') as output: + for line in proc.stdout: + print(line, end='', flush=True) + output.write(line) + if proc.wait(): + raise subprocess.CalledProcessError(proc.returncode, command) + + +def cached_jar(artifact: str, version: str = '*') -> Path: + paths = sorted((CACHE / artifact).glob(version + '/*/*.jar')) + if not paths: + raise RuntimeError(f'Missing cached compiler dependency: {artifact}:{version}') + return paths[-1] + + +def resolve() -> list[Path]: + build = WORK / 'build.gradle' + fingerprint = hashlib.sha256(BUILD.encode()).hexdigest() + stamp = WORK / 'dependency-fingerprint' + manifest = WORK / 'classpath.txt' + fresh = stamp.exists() and stamp.read_text() == fingerprint and manifest.exists() + if fresh: + fresh = all(Path(line).exists() for line in manifest.read_text().splitlines()) + if not fresh: + build.write_text(BUILD) + (WORK / 'settings.gradle').write_text("rootProject.name = 'reelbox-overlay-host-tests'\n") + (WORK / 'gradle.properties').write_text('org.gradle.jvmargs=-Xmx256m -XX:MaxMetaspaceSize=192m\norg.gradle.workers.max=1\norg.gradle.daemon=false\norg.gradle.vfs.watch=false\n') + candidates = sorted((Path.home() / '.gradle/wrapper/dists').glob('gradle-9.3.1-bin/*/gradle-9.3.1/bin/gradle')) + gradle = os.environ.get('OVERLAY_TEST_GRADLE') or (str(candidates[0]) if candidates else shutil.which('gradle')) + if not gradle: + raise RuntimeError('No Gradle 9.3.1 distribution found; set OVERLAY_TEST_GRADLE') + run([gradle, '--no-daemon', '--max-workers=1', '--console=plain', 'resolveHarness'], log='dependency-resolution.log') + stamp.write_text(fingerprint) + jars = [] + for line in manifest.read_text().splitlines(): + path = Path(line) + if path.suffix == '.aar': + destination = WORK / 'aar-jars' / (path.stem + '-classes.jar') + destination.parent.mkdir(exist_ok=True) + if not destination.exists(): + with zipfile.ZipFile(path) as aar: + destination.write_bytes(aar.read('classes.jar')) + jars.append(destination) + else: + jars.append(path) + return jars + + +def main() -> int: + global LOCK_FD + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--setup-only', action='store_true') + parser.add_argument('--test', help='Run one PlayerOverlayAndroidTest method') + args = parser.parse_args() + WORK.mkdir(parents=True, exist_ok=True) + (WORK / 'tmp').mkdir(exist_ok=True) + # Serialize the complete small pipeline, not separate JVMs that let a full + # app verification jump into the middle of compilation and test execution. + print(f'Acquiring flock {LOCK}', flush=True) + lock_file = LOCK.open('a') + fcntl.flock(lock_file, fcntl.LOCK_EX) + LOCK_FD = lock_file.fileno() + if not (JDK / 'bin/java').is_file() or not ANDROID.is_file(): + raise RuntimeError('JDK17 or Android compile jar missing; see environment overrides in script') + deps = resolve() + stdlib = cached_jar('org.jetbrains.kotlin/kotlin-stdlib', '2.4.0') + sources = WORK / 'sources' + sources.mkdir(exist_ok=True) + (sources / 'OverlayHarnessSmokeTest.java').write_text(SMOKE) + (sources / 'OverlayHostRunner.java').write_text(RUNNER) + classes = WORK / 'classes' + if classes.exists(): + shutil.rmtree(classes) + classes.mkdir() + cp = os.pathsep.join(map(str, [classes, ANDROID, stdlib, *deps])) + javac = [str(JDK / 'bin/javac'), '-J-Xmx192m', '--release', '17', '-cp', cp, '-d', str(classes)] + run([*javac, str(sources / 'OverlayHarnessSmokeTest.java'), str(sources / 'OverlayHostRunner.java')], log='javac-smoke.log') + opens = ['java.base/java.lang', 'java.base/java.util', 'java.base/java.io', + 'java.base/java.net', 'java.base/java.security', 'java.base/java.text', + 'java.base/jdk.internal.access', 'java.desktop/java.awt.font', + 'jdk.compiler/com.sun.tools.javac.api'] + java = [str(JDK / 'bin/java'), '-Xmx384m', '-XX:MaxMetaspaceSize=192m', + f'-Djava.io.tmpdir={WORK / "tmp"}', + '-Drobolectric.dependency.repo.url=https://repo.maven.apache.org/maven2', + f'-Dmaven.repo.local={WORK / "m2"}', + '-Drobolectric.graphicsMode=LEGACY', + *['--add-opens=' + item + '=ALL-UNNAMED' for item in opens], + '-cp', cp, 'org.reelbox.reelbox.OverlayHostRunner'] + # The SDK resolver downloads only the requested SDK28 instrumented Android jar. + # A separate smoke run establishes that infrastructure errors aren't overlay failures. + run([*java, 'org.reelbox.reelbox.OverlayHarnessSmokeTest', str(WORK / 'smoke-results.xml')], log='smoke.log') + if args.setup_only: + print('SETUP VERIFIED: SDK28 / Java17 / Robolectric4.16.1 / PAUSED / LEGACY. No production-overlay tests requested.') + return 0 + source = ROOT / 'android/app/src/main/kotlin/org/reelbox/reelbox/PlayerOverlayView.kt' + if not source.exists(): + print(f'BLOCKED: production overlay does not exist yet: {source}', file=sys.stderr) + return 2 + compiler = [cached_jar('org.jetbrains.kotlin/kotlin-compiler-embeddable', '2.4.0'), stdlib, + cached_jar('org.jetbrains.kotlin/kotlin-script-runtime', '2.4.0'), + cached_jar('org.jetbrains.kotlin/kotlin-reflect'), + cached_jar('org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm'), + cached_jar('org.jetbrains/annotations')] + trove = sorted((CACHE / 'org.jetbrains.intellij.deps/trove4j').glob('*/*/*.jar')) + compiler += trove[-1:] + # Compile real production source, never a replacement or copied overlay implementation. + overlay_sources = [source, source.with_name('PlayerControlsState.kt')] + state_source = source.with_name('PlayerOverlayState.kt') + if state_source.exists(): + overlay_sources.append(state_source) + test = ROOT / 'android/app/src/test/java/org/reelbox/reelbox/PlayerOverlayAndroidTest.java' + input_hashes = {str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [*overlay_sources, test]} + (WORK / 'tested-inputs.json').write_text(json.dumps({ + 'robolectric': '4.16.1', 'runtime_sdk': 28, 'java': 17, + 'looper': 'PAUSED', 'graphics': 'LEGACY', 'sha256': input_hashes, + }, indent=2) + '\n') + run([str(JDK / 'bin/java'), '-Xmx256m', f'-Djava.io.tmpdir={WORK / "tmp"}', '-cp', os.pathsep.join(map(str, compiler)), + 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', '-jvm-target', '17', '-no-stdlib', '-no-reflect', + '-classpath', cp, '-d', str(classes), *map(str, overlay_sources)], log='kotlin-compile.log') + run([*javac, str(test)], log='javac-overlay.log') + command = [*java, 'org.reelbox.reelbox.PlayerOverlayAndroidTest', str(WORK / 'overlay-results.xml')] + if args.test: + command.append(args.test) + run(command, log='overlay.log') + for path, digest in input_hashes.items(): + if hashlib.sha256(Path(path).read_bytes()).hexdigest() != digest: + raise RuntimeError(f'Source changed during test execution; rerun before claiming verification: {path}') + print('Verified real Android view events, Handler timing and layout on host LEGACY graphics; not a device pixel/rendering test.') + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except subprocess.CalledProcessError as error: + print(f'FAILED: command exited {error.returncode}; logs: {WORK}', file=sys.stderr) + sys.exit(error.returncode) + except (OSError, RuntimeError) as error: + print(f'BLOCKED: {error}', file=sys.stderr) + sys.exit(2) diff --git a/tool/test_release_signer.py b/tool/test_release_signer.py new file mode 100644 index 0000000..3f38333 --- /dev/null +++ b/tool/test_release_signer.py @@ -0,0 +1,21 @@ +"""Release identity verification must accept only the explicitly pinned signer.""" +import unittest +from verify_apk import verified_signer + +SHA = 'd684537f3637fde36c9e7b065b5acd78499c02d345c34bf87857b13ed1d8b87d' + + +class SignerTests(unittest.TestCase): + def test_authorized_replacement_key_has_its_actual_subject(self): + text = f'Signer #1 certificate DN: CN=Reelbox Release, OU=Reelbox\nSigner #1 certificate SHA-256 digest: {SHA}\n' + self.assertEqual(verified_signer(text, SHA), (SHA, 'CN=Reelbox Release, OU=Reelbox')) + + def test_mismatched_or_debug_signer_is_rejected(self): + with self.assertRaises(AssertionError): + verified_signer(f'Signer #1 certificate DN: CN=Reelbox Release\nSigner #1 certificate SHA-256 digest: {SHA}', 'a' * 64) + with self.assertRaises(AssertionError): + verified_signer(f'Signer #1 certificate DN: CN=Android Debug\nSigner #1 certificate SHA-256 digest: {SHA}', SHA) + + +if __name__ == '__main__': + unittest.main() diff --git a/tool/test_release_version.py b/tool/test_release_version.py new file mode 100644 index 0000000..331c5eb --- /dev/null +++ b/tool/test_release_version.py @@ -0,0 +1,25 @@ +"""A stale delivered APK must not pass the current-release gate.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +class ReleaseVersionTests(unittest.TestCase): + def test_previous_apk_is_rejected_for_current_source_version(self): + root = Path(__file__).resolve().parents[1] + old = root.parent / 'reelbox-delivery/reelbox-0.2.0-arm64.apk' + if not old.exists(): + self.skipTest('Previous delivery not available on this verification host') + with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as temp: + result = subprocess.run([sys.executable, str(root / 'tool/verify_apk.py'), + str(old), '--output-dir', temp], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0, 'Verifier accepted the stale 0.2.0 APK for 0.3.0 source') + self.assertIn('Wrong package/version', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tool/test_website_copy.py b/tool/test_website_copy.py new file mode 100644 index 0000000..c4b8771 --- /dev/null +++ b/tool/test_website_copy.py @@ -0,0 +1,58 @@ +"""Regression checks for the public download-page copy.""" +from html.parser import HTMLParser +from pathlib import Path +import unittest + + +class Page(HTMLParser): + def __init__(self, text): + super().__init__() + self.links = [] + self.visible = [] + self.header = False + self.header_links = [] + self.feed(text) + + def handle_starttag(self, tag, attrs): + if tag == 'header': + self.header = True + if tag == 'a': + href = dict(attrs).get('href', '') + self.links.append(href) + if self.header: + self.header_links.append(href) + + def handle_endtag(self, tag): + if tag == 'header': + self.header = False + + def handle_data(self, data): + self.visible.append(data) + + +class WebsiteCopyTests(unittest.TestCase): + def setUp(self): + self.page = Page((Path(__file__).resolve().parents[1] / 'website/index.html').read_text()) + + def test_header_has_no_github_source_link(self): + self.assertFalse(any('github.com' in href for href in self.page.header_links)) + self.assertNotIn('Source on GitHub', ''.join(self.page.visible)) + + def test_release_has_no_upgrade_comment(self): + self.assertNotIn('Updates v0.4.2 without uninstalling', ''.join(self.page.visible)) + + def test_footer_has_no_provider_disclaimer(self): + self.assertNotIn( + 'External catalog and media providers. Use only media you’re entitled to access.', + ''.join(self.page.visible), + ) + + def test_version_download_and_source_archive_remain(self): + self.assertIn('v0.4.4', ''.join(self.page.visible)) + self.assertIn('/downloads/reelbox-0.4.4-arm64.apk', self.page.links) + self.assertIn('/downloads/reelbox-0.4.4-source.zip', self.page.links) + self.assertIn('Source archive', ''.join(self.page.visible)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tool/ui_capture_test.dart b/tool/ui_capture_test.dart new file mode 100644 index 0000000..756291e --- /dev/null +++ b/tool/ui_capture_test.dart @@ -0,0 +1,134 @@ +// Optional host-rendered UI evidence; not Android device verification. +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:reelbox/app.dart'; +import 'package:reelbox/data/api_service.dart'; +import 'package:reelbox/data/models.dart'; +import 'package:reelbox/state/app_state.dart'; +import 'package:reelbox/ui/library.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _CaptureApi extends ApiService { + @override + Future> home() async => { + 'genres': [], + 'actors': [], + 'countries': [], + }; + @override + Future>> actors({ + int page = 1, + String search = '', + }) async => []; + @override + Future> countries() async => []; + @override + Future> genres() async => []; + @override + Future> trending() async => []; + @override + Future> bestRandom({ + bool byYear = false, + int page = 1, + }) async => []; +} + +void main() { + testWidgets( + 'mobile local-library and settings UI renders in English and Persian', + (tester) async { + await tester.runAsync(() async { + final font = File( + Platform.environment['REELBOX_TEST_FONT'] ?? + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + ); + final bytes = await font.readAsBytes(); + await (FontLoader( + 'Roboto', + )..addFont(Future.value(bytes.buffer.asByteData()))).load(); + await (FontLoader( + 'Ahem', + )..addFont(Future.value(bytes.buffer.asByteData()))).load(); + final icons = await File( + Platform.environment['REELBOX_ICON_FONT'] ?? '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf', + ).readAsBytes(); + await (FontLoader( + 'MaterialIcons', + )..addFont(Future.value(icons.buffer.asByteData()))).load(); + }); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + // Explicitly invoked test harness; kept outside default test discovery. + // ignore: invalid_use_of_visible_for_testing_member + SharedPreferences.setMockInitialValues({}); + final state = AppState( + api: _CaptureApi(), + storage: await SharedPreferences.getInstance(), + ); + await state.initialize(discover: false); + final boundaryKey = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: MyApp(state: state), + ), + ); + await tester.pumpAndSettle(); + final output = Directory( + Platform.environment['REELBOX_SCREENSHOT_DIR'] ?? + '${Directory.current.path}/build/ui-verification', + ); + await tester.runAsync(() => output.create(recursive: true)); + Future capture(String name) async { + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final boundary = + boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await File('${output.path}/$name.png') + .writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); + } + + expect(find.byType(FirstRunIntroPage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + await capture('intro-en'); + await state.updateSettings(state.prefs.copyWith(locale: 'fa')); + await tester.pumpAndSettle(); + expect(find.byType(FirstRunIntroPage), findsOneWidget); + await capture('intro-fa'); + await state.updateSettings( + state.prefs.copyWith(locale: 'en', introCompleted: true), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Library').last); + expect(find.byType(NavigationBar), findsOneWidget); + await capture('library-en'); + await tester.tap(find.text('More').last); + await capture('more-en'); + await tester.tap(find.text('Settings')); + await capture('settings-en'); + await state.updateSettings(state.prefs.copyWith(locale: 'fa')); + await capture('settings-fa'); + await state.updateSettings(state.prefs.copyWith(locale: 'en')); + await tester.drag(find.byType(ListView), const Offset(0, -10000)); + expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing); + await capture('settings-data-en'); + await state.updateSettings(state.prefs.copyWith(locale: 'fa')); + await capture('settings-data-fa'); + await tester.pumpWidget(const SizedBox()); + state.dispose(); + }, + ); +} diff --git a/tool/verify_apk.py b/tool/verify_apk.py new file mode 100644 index 0000000..f6df312 --- /dev/null +++ b/tool/verify_apk.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Verify Reelbox's signed ARM64 release APK without reading signing secrets.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import zipfile + + +def digest(path): + value = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + value.update(chunk) + return value.hexdigest() + + +def verified_signer(signature, expected_sha256=None): + signer = re.search(r'Signer #1 certificate SHA-256 digest: ([0-9a-fA-F]{64})\b', signature) + subject = re.search(r'^Signer #1 certificate DN: (.+)$', signature, re.M) + assert signer and subject, 'No release certificate identity' + assert 'Android Debug' not in subject.group(1), 'Debug certificate is not a release signer' + fingerprint = signer.group(1).lower() + if expected_sha256: + assert fingerprint == expected_sha256.lower(), 'Wrong release certificate fingerprint' + return fingerprint, subject.group(1) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('apk', type=Path) + parser.add_argument('--android-sdk', type=Path, default=Path(os.environ.get('ANDROID_HOME', '/root/developer-tools/android'))) + parser.add_argument('--build-tools', default='36.0.0') + parser.add_argument('--baseline-apk', type=Path) + parser.add_argument('--expected-signer-sha256', help='Pin the authorized release certificate, including a replacement key') + parser.add_argument('--output-dir', type=Path, required=True) + args = parser.parse_args() + source_version = re.search(r'^version:\s*([^+\s]+)\+(\d+)\s*$', + (Path(__file__).resolve().parents[1] / 'pubspec.yaml').read_text(), re.M) + assert source_version, 'Source version must include an explicit release name and code' + expected_name, expected_code = source_version.groups() + args.output_dir.mkdir(parents=True, exist_ok=True) + tools = args.android_sdk / 'build-tools' / args.build_tools + + def run(tool, *arguments): + result = subprocess.run([str(tools / tool), *map(str, arguments)], capture_output=True, text=True, check=True) + return result.stdout + result.stderr + + badging = run('aapt', 'dump', 'badging', args.apk) + signature = run('apksigner', 'verify', '--verbose', '--print-certs', args.apk) + (args.output_dir / 'apk-badging.log').write_text(badging) + (args.output_dir / 'apk-signature.log').write_text(signature) + package = re.search(r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'", badging) + assert package and package.groups() == ('org.reelbox.app', expected_code, expected_name), 'Wrong package/version; never deliver a stale APK' + assert 'application-debuggable' not in badging, 'Debuggable APK' + minimum = re.search(r"^sdkVersion:'(\d+)'", badging, re.M) + target = re.search(r"^targetSdkVersion:'(\d+)'", badging, re.M) + assert minimum and minimum.group(1) == '24' + assert target and target.group(1) == '36' + signer_sha256, signer_subject = verified_signer(signature, args.expected_signer_sha256) + with zipfile.ZipFile(args.apk) as archive: + assert archive.testzip() is None, 'APK ZIP corruption' + names = archive.namelist() + abis = sorted({name.split('/')[1] for name in names if name.startswith('lib/') and name.endswith('.so')}) + assert abis == ['arm64-v8a'], 'Unsupported or dependency-only ABI advertised' + for library in ['libapp.so', 'libflutter.so', 'libmpv.so']: + assert 'lib/arm64-v8a/' + library in names, 'Missing ' + library + assert 'assets/flutter_assets/kernel_blob.bin' not in names, 'Debug kernel found' + baseline_matches = None + if args.baseline_apk: + baseline = run('apksigner', 'verify', '--print-certs', args.baseline_apk) + old = re.search(r'Signer #1 certificate SHA-256 digest: ([0-9a-fA-F]+)', baseline) + assert old and old.group(1).lower() == signer_sha256, 'Cannot upgrade the delivered beta: signer mismatch' + baseline_matches = True + report = { + 'passed': True, 'apk': str(args.apk.resolve()), 'bytes': args.apk.stat().st_size, + 'sha256': digest(args.apk), 'package': package.group(1), 'version_code': int(package.group(2)), + 'version_name': package.group(3), 'min_sdk': 24, 'target_sdk': 36, 'abis': abis, + 'release_signer': signer_subject, 'signer_sha256': signer_sha256, + 'same_signer_as_delivered_beta': baseline_matches, 'zip_integrity': True, + 'core_native_libraries_present': True, 'debuggable': False, + 'android_hardware_verified': False, + } + (args.output_dir / 'apk-verification.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tool/verify_host.py b/tool/verify_host.py new file mode 100644 index 0000000..e8de85c --- /dev/null +++ b/tool/verify_host.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Run all Flutter test files, including real-worker host registration. + +The multipart file needs a Linux path-provider registrant in spawned isolates. +Run it in its own invocation, not by skipping its cases in the normal suite. +All Flutter tooling shares the project's existing lock. This is host evidence, +not Android hardware or process-kill/restart verification. +""" +from __future__ import annotations + +import argparse +import fcntl +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--flutter', default=shutil.which('flutter')) + parser.add_argument('--output-dir', type=Path) + args = parser.parse_args() + if not args.flutter: + parser.error('Flutter was not found; provide --flutter /path/to/flutter') + root = Path(__file__).resolve().parents[1] + output = (args.output_dir or root / 'build' / 'host-verification').resolve() + output.mkdir(parents=True, exist_ok=True) + files = sorted(p.relative_to(root).as_posix() for p in (root / 'test').rglob('*_test.dart')) + multipart = 'test/download_multipart_http_test.dart' + if multipart not in files: + parser.error('Required real-worker multipart test file is missing') + ordinary = [p for p in files if p != multipart] + common = [args.flutter, 'test', '--no-pub', '--concurrency=1', '--reporter=expanded', '--timeout=90s'] + stages = [ + ('analysis', [args.flutter, 'analyze'], []), + ('ordinary', common + ordinary, ordinary), + ('multipart', common + ['--dart-define=flutter.dart_plugin_registrant=' + (root / multipart).as_uri(), multipart], [multipart]), + ] + report = { + 'scope': 'all Flutter test files; real multipart HTTP/workers run separately with required host registration', + 'android_hardware_verified': False, + 'expected_test_files': files, + 'covered_test_files': [], + 'stages': [], + 'passed': False, + } + result = 0 + with (root / '.tooling.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + for name, command, covered in stages: + log = output / (name + '.log') + print('Running ' + name + ' -> ' + str(log), flush=True) + try: + with log.open('w') as stream: + code = subprocess.run(command, cwd=root, stdout=stream, stderr=subprocess.STDOUT, timeout=1200, check=False).returncode + except subprocess.TimeoutExpired: + code = 124 + text = log.read_text(errors='replace') + counts = re.findall(r'\+(\d+)(?: -\d+)?: All tests passed!', text) + stage = {'name': name, 'command': command, 'exit_code': code, 'log': str(log)} + if covered: + stage['passed_test_count'] = int(counts[-1]) if counts else None + if code == 0 and not counts: + code = 1 + stage['exit_code'] = code + stage['verification_error'] = 'Missing successful nonempty test summary' + report['stages'].append(stage) + if code == 0: + report['covered_test_files'].extend(covered) + else: + result = code + break + report['passed'] = result == 0 and sorted(report['covered_test_files']) == files + if report['passed']: + report['passed_test_count'] = sum(stage.get('passed_test_count', 0) for stage in report['stages']) + (output / 'verification.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({key: report[key] for key in ['passed', 'expected_test_files', 'covered_test_files']}), flush=True) + print('Verification report: ' + str(output / 'verification.json'), flush=True) + return 0 if report['passed'] else (result or 1) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7e8c681 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + reelbox + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..403ca45 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "reelbox", + "short_name": "reelbox", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/website/assets/reelbox-intro.webp b/website/assets/reelbox-intro.webp new file mode 100644 index 0000000..e10a7b6 Binary files /dev/null and b/website/assets/reelbox-intro.webp differ diff --git a/website/deploy/nginx.conf b/website/deploy/nginx.conf new file mode 100644 index 0000000..72d8102 --- /dev/null +++ b/website/deploy/nginx.conf @@ -0,0 +1,57 @@ +user www-data; +worker_processes 1; +pid /run/reelbox-web/nginx.pid; +error_log /var/log/reelbox/error.log warn; +events { worker_connections 256; } +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + client_body_temp_path /run/reelbox-web/client_temp; + proxy_temp_path /run/reelbox-web/proxy_temp; + fastcgi_temp_path /run/reelbox-web/fastcgi_temp; + uwsgi_temp_path /run/reelbox-web/uwsgi_temp; + scgi_temp_path /run/reelbox-web/scgi_temp; + server_tokens off; + sendfile on; + tcp_nopush on; + keepalive_timeout 30; + client_max_body_size 1k; + access_log off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:ReelboxTLS:1m; + ssl_session_timeout 1d; + ssl_session_tickets off; + server { + # Verify ownership/firewall rules on a new host before enabling 443. + listen 443 ssl http2; + listen [::]:443 ssl http2; + # Keep the legacy listener available for previously released clients. + listen 8443 ssl http2; + listen [::]:8443 ssl http2; + server_name reelbox.dartty.top; + ssl_certificate /etc/letsencrypt/live/reelbox.dartty.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/reelbox.dartty.top/privkey.pem; + include /etc/reelbox/api-locations.conf; + root /var/www/reelbox/current; + index index.html; + include /etc/reelbox/security-headers.conf; + location / { + limit_except GET { deny all; } + try_files $uri $uri/ =404; + } + location /downloads/ { + limit_except GET { deny all; } + types { + application/vnd.android.package-archive apk; + application/zip zip; + text/plain txt; + } + autoindex off; + max_ranges 1; + try_files $uri =404; + include /etc/reelbox/security-headers.conf; + add_header Content-Disposition "attachment"; + } + location ~ /\. { return 404; } + } +} diff --git a/website/deploy/reelbox-web.service b/website/deploy/reelbox-web.service new file mode 100644 index 0000000..f3d7ca4 --- /dev/null +++ b/website/deploy/reelbox-web.service @@ -0,0 +1,30 @@ +[Unit] +Description=Reelbox HTTPS download website +After=network.target + +[Service] +Type=simple +RuntimeDirectory=reelbox-web +RuntimeDirectoryMode=0755 +ExecStartPre=/usr/sbin/nginx -t -c /etc/reelbox/nginx.conf +ExecStart=/usr/sbin/nginx -c /etc/reelbox/nginx.conf -g "daemon off;" +ExecReload=/usr/sbin/nginx -t -c /etc/reelbox/nginx.conf +ExecReload=/usr/sbin/nginx -c /etc/reelbox/nginx.conf -s reload +KillSignal=SIGQUIT +TimeoutStopSec=15 +Restart=on-failure +RestartSec=3 +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ReadWritePaths=/run/reelbox-web /var/log/reelbox +MemoryMax=64M +TasksMax=16 +LimitNOFILE=2048 + +[Install] +WantedBy=multi-user.target diff --git a/website/deploy/renewal-hook.sh b/website/deploy/renewal-hook.sh new file mode 100644 index 0000000..63d111d --- /dev/null +++ b/website/deploy/renewal-hook.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +if [ "${RENEWED_LINEAGE:-}" = /etc/letsencrypt/live/reelbox.dartty.top ]; then + if systemctl is-active --quiet reelbox-web.service; then + /usr/sbin/nginx -t -c /etc/reelbox/nginx.conf + systemctl reload reelbox-web.service + fi +fi diff --git a/website/deploy/security-headers.conf b/website/deploy/security-headers.conf new file mode 100644 index 0000000..d2e5ea3 --- /dev/null +++ b/website/deploy/security-headers.conf @@ -0,0 +1,6 @@ +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "no-referrer" always; +add_header X-Frame-Options "DENY" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests" always; +add_header Strict-Transport-Security "max-age=86400" always; diff --git a/website/favicon.svg b/website/favicon.svg new file mode 100644 index 0000000..0c60c43 --- /dev/null +++ b/website/favicon.svg @@ -0,0 +1 @@ + diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..6fe417e --- /dev/null +++ b/website/index.html @@ -0,0 +1,53 @@ + + + + + + + + Reelbox — Your library, your device + + + + + + +
+
+ Reelbox + +
+
+
+
+

For Android · Open source

+

Films, series.
Your own library.

+

Watch films and series, save favourites and download for offline viewing. No account or subscription.

+ +

v0.4.4 · ARM64 · Android 7.0+

+ +

Your watchlist and history stay on your device

+
+
+
Reelbox’s first-run screen, with English and Persian language choices and the Start watching button
+
Inside Reelbox English / فارسی
+
+
+
+ +
+ + + diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..0b12641 --- /dev/null +++ b/website/styles.css @@ -0,0 +1,48 @@ +:root{color-scheme:dark;--bg:#0d0e11;--surface:#111216;--border:#303034;--ink:#f0e7e5;--secondary:#b8aeac;--muted:#99918f;--accent:#ff8075;--on-accent:#261312} +*{box-sizing:border-box} +html{min-width:320px;background:var(--bg);scroll-behavior:auto} +body{margin:0;color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;font-size:16px;font-weight:400;line-height:1.5;-webkit-font-smoothing:antialiased} +a{color:inherit;text-decoration:none;transition:color 140ms ease,background-color 140ms ease,border-color 140ms ease} +a:hover{color:var(--ink)} +a:focus-visible{outline:2px solid var(--accent);outline-offset:6px;border-radius:4px} +svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;flex:none} +.page{width:min(1184px,100%);margin:0 auto;padding:0 32px;min-height:100svh;display:flex;flex-direction:column} +header{display:flex;align-items:center;justify-content:space-between;gap:24px;min-height:104px;border-bottom:1px solid var(--border)} +.wordmark{font-size:28px;font-weight:500;line-height:36px;letter-spacing:-.6px} +.source-link{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:var(--secondary)} +main{flex:1;display:flex;align-items:center} +.hero{width:100%;display:grid;grid-template-columns:minmax(0,1fr) 320px;align-items:center;gap:80px;padding:56px 0 64px} +.copy{max-width:640px} +p,h1,figure{margin:0} +.eyebrow{font-size:14px;line-height:20px;color:var(--secondary);margin-bottom:24px} +h1{font-size:clamp(48px,5.2vw,72px);line-height:1.06;letter-spacing:-2.4px;font-weight:400;text-wrap:balance} +.description{max-width:460px;font-size:18px;line-height:28px;color:var(--secondary);margin-top:28px} +.actions{display:flex;align-items:center;gap:16px;margin-top:36px} +.download{display:inline-flex;justify-content:center;align-items:center;gap:12px;min-height:52px;padding:12px 24px;border:1px solid transparent;border-radius:8px;background:var(--accent);color:var(--on-accent);font-weight:500;font-size:16px;line-height:24px} +.download:hover{color:var(--on-accent);background:#ff958c} +.download:active{background:#f47268} +.release{margin-top:16px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:20px;font-variant-numeric:tabular-nums;color:var(--muted)} +.release span{padding:0 4px} +.private-note{display:flex;align-items:center;gap:12px;margin-top:36px;color:var(--secondary);font-size:14px;line-height:20px} +.private-note svg{color:var(--muted)} +.product{width:320px} +.screen{padding:12px;border:1px solid var(--border);border-radius:24px;background:var(--surface);box-shadow:0 16px 40px #0003;height:444px;overflow:hidden} +.screen img{display:block;width:100%;height:auto;border-radius:12px} +figcaption{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;font-size:12px;line-height:20px;color:var(--muted)} +figcaption span{color:var(--secondary)} +footer{border-top:1px solid var(--border);padding:24px 0 28px;display:grid;grid-template-columns:1fr auto;gap:16px 32px;font-size:12px;line-height:20px;color:var(--muted)} +.credit{font-size:14px;color:var(--ink)} +.fine-print{margin-top:4px} +.copy .fine-print{font-size:12px;line-height:20px;color:var(--muted)} +.footer-links{display:flex;align-items:flex-start;gap:24px;padding-top:4px;color:var(--secondary)} +.footnote{grid-column:1/-1;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px 24px;font-size:11px;line-height:16px} +.footnote a{text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--border)} +.skip{position:absolute;left:16px;top:-80px;padding:12px 16px;background:var(--ink);color:var(--bg);z-index:10} +.skip:focus{top:16px} +@media(min-width:1600px){.hero{gap:112px;padding-top:80px;padding-bottom:80px}} +@media(max-width:960px){.hero{grid-template-columns:minmax(0,1fr) 280px;gap:40px}h1{font-size:56px;letter-spacing:-1.6px}.product{width:280px}.screen{height:392px;border-radius:24px}.description{font-size:16px;line-height:24px}.release span{padding:0 2px}} +@media(max-width:720px){.page{padding:0 24px}header{min-height:80px}.wordmark{font-size:24px;line-height:32px}.source-link{font-size:12px}.hero{grid-template-columns:1fr;gap:48px;padding:48px 0}.copy{max-width:520px}h1{font-size:48px;line-height:52px;letter-spacing:-1.6px}.eyebrow{margin-bottom:20px}.description{margin-top:24px;max-width:400px}.actions{margin-top:28px}.release{font-size:11px;line-height:20px}.private-note{margin-top:28px;font-size:12px}.product{width:min(320px,100%);justify-self:center}.screen{height:444px}.footer-links{gap:20px;flex-wrap:wrap}footer{grid-template-columns:1fr;gap:20px}.footnote{grid-column:auto;display:grid;gap:12px}.footnote span{display:block}} +@media(max-width:380px){.page{padding:0 20px}.source-link{gap:4px}h1{font-size:40px;line-height:44px;letter-spacing:-1.2px}.download{padding:12px 20px;font-size:14px}.product{width:280px}.screen{height:392px}} +@media(prefers-reduced-motion:reduce){a{transition:none}} +/* Design process: Unslop by Damir Sokolovsky (@heydamir), CC BY 4.0. + https://heydamir.design/unslop — original Reelbox implementation. */