diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..a1feffe931 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +continuation_indent_size = 4 +charset = utf-8 diff --git a/.gitignore b/.gitignore index a0e596df0b..63dc49041b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,22 @@ -.classpath .project .settings bin gen +out .DS_Store +local.properties +target +gen-external-apklibs +*.iml +.idea +classes +ApiKeys.java +.gradle +gradle.properties +app/src/beta +app/src/release +app/seed.txt +build/ +*/crashlytics.properties +crashlytics-build.properties +com_crashlytics_export_strings.xml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..7e8930997a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +language: android + +jdk: +- oraclejdk8 + +android: + components: + - tools + - platform-tools + - build-tools-25.0.2 + - android-25 + - android-22 # required for emulator below + - extra-android-m2repository + - sys-img-armeabi-v7a-android-22 + +before_script: + # Create and start emulator + - echo no | android create avd --force -n test -t android-22 --abi armeabi-v7a + - emulator -avd test -no-skin -no-audio -no-window & + - android-wait-for-emulator + - adb shell input keyevent 82 & + +script: + - ./gradlew clean testMadaniRelease -PdisableCrashlytics + - ./gradlew clean connectedMadaniDebugAndroidTest -PdisableCrashlytics diff --git a/AndroidManifest.xml b/AndroidManifest.xml deleted file mode 100644 index 0d6c9ba70d..0000000000 --- a/AndroidManifest.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000000..c46cb961c3 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,46 @@ +quran android contributors +========================== + +### code + +* [Ahmed El-Helw](https://twitter.com/ahmedre) +* [Asim Mohiuddin](https://github.com/asimmohiuddin) - images, gapless audio + and work on the Naskh version +* [g360230](https://github.com/g360230) - images and work on the Qaloon + version +* [Shuhrat Dehkanov](http://github.com/ozbek) +* [Ahmed Farra](http://github.com/afarra) +* [Hussein Maher](http://twitter.com/husseinmaher) +* [Wael Nafee](http://twitter.com/wnafee) +* [Ahmed Fouad](http://twitter.com/fo2ad) +* [Mahmoud Hossam](http://github.com/mahmoudhossam) +* [Rehab Mohamed](http://twitter.com/hams_rrr) +* [Ahmed Essam](http://twitter.com/neo_4583) +* [Hosain Al Ahmad](https://github.com/hosainnet) +* [Ahmed Abdelaal](https://github.com/Ahmed9914) + +### ui and design +* [Somaia Gabr](http://twitter.com/somaiagabr). + +### translators + +* Farsi for version 2.0 by [M. Jafar Nakar](https://github.com/mjnanakar). +* Farsi for version 1.6 by [khajavi](http://github.com/khajavi). +* Turkish by Mehmed Mahmudoglu. +* Turkish updates by [Shuhrat Dehkanov](http://github.com/ozbek). +* Russian by Rinat [Ринат Валеев](https://github.com/Valey). +* Kurdish by [Goran Gharib Karim](https://github.com/GorranKurd). +* French by Yasser [yasserkad](http://github.com/yasserkad). +* French updates by [Abdullah ibn Nadjo](https://github.com/abdullahibnnadjo). +* German by [Armin Supuk](http://github.com/ArminSupuk). +* Chinese by [Bo Li](http://twitter.com/liboat). +* Uyghur by Abduqadir Abliz [Sahran](http://github.com/Sahran). +* Indonesian by [Saiful Khaliq](http://twitter.com/saifious). +* Malaysian by [Ahmad Syazwan](https://github.com/asyazwan). +* Spanish by [Alexander J. Salas B.](https://github.com/ajsb85). +* Uzbek by [Shuhrat Dehkanov](https://github.com/ozbek"). + + +### and many more +* everyone we missed from the above lists - may Allah reward you! +* everyone who notified us about any bugs, made du3a for us, or that shared the app with family and friends. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..9cecc1d466 --- /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. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + 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: + + {project} Copyright (C) {year} {fullname} + 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 index 94eee0a5d4..bbaff70b80 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,43 @@ -quran for android -================================== - -this is a simple (madani based) quran app for android. for details about -the images used, please check out the -[quran images project](http://github.com/quran/quran.com-images) on github. -translation and arabic data come from [tanzil](http://tanzil.net). - -patches, comments, etc are welcome. - -code by [@ahmedre](http://twitter.com/ahmedre), -[@HusseinMaher](http://twitter.com/husseinmaher), -and [@wnafee](http://twitter.com/wnafee). graphics by -[@somaiagabr](http://twitter.com/somaiagabr). - -terms of use ------------- -you are free to use parts of (or all of) the Quran Android code in your -application with some conditions: - -* if you write an application using any of the Quran data (the images, the -translations, etc), you must provide a link to the respective data source -page ([tanzil.net](http://tanzil.net) for the translations and the -[quran images project](http://github.com/quran/quran.com-images) for the images) -both within your application (in an about page) and in your application -description in the market or app store. - -* if you use part of (or all of) the quran android code or graphics, you -must provide a link back to the [quran android -project](http://github.com/ahmedre/quran_android) in your application -description and your application itself in an about section. - -changelog ---------- -**version 1.3** - -- improved interface -- support for 1024x768 images -- translation download support -- arabic support for non-arabic supporting devices -- initial search support via search button -- more translations -- bugfixes and more - -**version 1.2** - -- Sahih Internation Translation introduced -- Fix orientation in either landscape or portrait modes -- Adjust translation text size -- Centralized menu for app -- Bookmarks are added via menu -- Fixed bookmarks bug - -**version 1.1** - -- added bookmarks -- updated browse to allow browsing by juz' -- remember the last place you left off -- added help dialog -- made the screen lock an option -- fixed a bug where the screen lock wasn't released - -**version 1.0** - -- initial release - -todo ----- -- add audio -- improve page scrolling -- downloads should resume -- add transliteration search -- code cleanup [strings localization, comments, etc] +[![Build Status](https://travis-ci.org/quran/quran_android.svg?branch=master)](https://travis-ci.org/quran/quran_android) + +# Quran for Android + +[](https://play.google.com/store/apps/details?id=com.quran.labs.androidquran) + +this is a simple (madani based) quran app for android. + +* madani images from [quran images project](https://github.com/quran/quran.com-images) on github. +* qaloon images used with permission of Nous Memes Editions Et Diffusion (Tunisia). +* naskh images used with permission of SHL Info Systems. +* translation, tafsir and Arabic data come from [tanzil](http://tanzil.net) and [King Saud University](https://quran.ksu.edu.sa). + +## Contributing + +If you'd like to contribute, please take a look at the [PRs Welcome](https://github.com/quran/quran_android/issues?q=is%3Aissue+is%3Aopen+label%3A%22PRs+Welcome%22) label on the issue tracker. For new features, please open an issue to discuss it before beginning implementation. + +Use [`quran_android-code_style.xml`](https://github.com/quran/quran_android/blob/master/quran_android-code_style.xml) for Android Studio / IntelliJ code styles. Import it by copying it to the Android Studio/IntelliJ IDEA codestyles folder. For Android Studio, that folder is located at `~/.AndroidStudio[Version]/config/codestyles` (the root folder name may differ depending on the host machine and Android Studio version, but the rest of the path should be same). After copying the `quran_android-code_style.xml`, go to Code Style preferences screen and choose `quran_android-code_style` from Code Style Schemes. + +May Allah reward all the awesome [Contributors and Translators](https://github.com/quran/quran_android/blob/master/CONTRIBUTORS.md). + + +## Setup + +### Command Line + +You can build Quran from the command line by running `./gradlew assembleMadaniDebug`. + +### Android Studio / IntelliJ + +Choose "Import Project," and choose the `build.gradle` file from the top level directory. Under "Build Variants" (a tab on the left side), choose "madaniDebug." + +## Open Source Projects Used + +* [Android Support Library](https://developer.android.com/topic/libraries/support-library/features.html) +* [AndroidSlidingUpPanel](https://github.com/umano/AndroidSlidingUpPanel) +* [OkHttp](https://github.com/square/okhttp) +* [RxJava 2](https://github.com/ReactiveX/RxJava) +* [RxAndroid](https://github.com/ReactiveX/RxAndroid) +* [moshi](https://github.com/square/moshi) +* [dagger2](http://google.github.io/dagger/) +* [retrolambda](https://github.com/orfjackal/retrolambda) and [retrolambda gradle plugin](https://github.com/evant/gradle-retrolambda). +* [butterknife](http://jakewharton.github.io/butterknife/) diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000000..b708f730ea --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,143 @@ +apply plugin: 'com.android.application' +apply plugin: 'io.fabric' +apply plugin: 'me.tatarka.retrolambda' + +android { + compileSdkVersion 25 + buildToolsVersion '25.0.2' + + lintOptions { + checkReleaseBuilds true + lintConfig file("lint.xml") + } + + defaultConfig { + minSdkVersion 14 + targetSdkVersion 25 + versionCode 2741 + versionName "2.7.4-p1" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + release { + storeFile file(STORE_FILE) + storePassword STORE_PASSWORD + keyAlias KEY_ALIAS + keyPassword KEY_PASSWORD + } + } + + productFlavors { + madani { + applicationId "com.quran.labs.androidquran" + } + + qaloon { + applicationId "com.quran.labs.androidquran.qaloon" + versionCode 1071 + versionName "1.0.7-p1" + } + + naskh { + applicationId "com.quran.labs.androidquran.naskh" + versionCode 1071 + versionName "1.0.7-p1" + } + + shemerly { + applicationId "com.quran.labs.androidquran.shemerly" + versionCode 1071 + versionName "1.0.7-p1" + } + + warsh { + applicationId "com.quran.labs.androidquran.warsh" + versionCode 1071 + versionName "1.0.7-p1" + } + } + + buildTypes { + beta { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard.cfg' + signingConfig signingConfigs.release + versionNameSuffix "-beta" + if (project.hasProperty('disableCrashlytics')) { + ext.enableCrashlytics = false + } + } + + debug { + ext.enableCrashlytics = false + applicationIdSuffix ".debug" + versionNameSuffix "-debug" + } + + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard.cfg' + signingConfig signingConfigs.release + if (project.hasProperty('disableCrashlytics')) { + ext.enableCrashlytics = false + } + } + } + + applicationVariants.all { variant -> + resValue "string", "authority", applicationId + '.data.QuranDataProvider' + resValue "string", "file_authority", applicationId + '.fileprovider' + if (applicationId.endsWith("debug")) { + mergedFlavor.manifestPlaceholders = [app_debug_label: "Quran " + flavorName.capitalize()] + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions.unitTests.all { + testLogging { + events 'passed', 'skipped', 'failed', 'standardOut', 'standardError' + outputs.upToDateWhen { false } + showStandardStreams true + exceptionFormat "full" + } + } +} + +ext { + supportLibVersion = '25.1.0' + espressoVersion = '2.2.2' +} + +dependencies { + compile "com.android.support:support-v4:${supportLibVersion}" + compile "com.android.support:appcompat-v7:${supportLibVersion}" + compile "com.android.support:recyclerview-v7:${supportLibVersion}" + compile "com.android.support:design:${supportLibVersion}" + compile 'io.reactivex.rxjava2:rxjava:2.0.4' + compile 'io.reactivex.rxjava2:rxandroid:2.0.1' + annotationProcessor 'com.google.dagger:dagger-compiler:2.8' + compile 'com.google.dagger:dagger:2.8' + compile 'com.squareup.okhttp3:okhttp:3.5.0' + compile 'com.squareup.moshi:moshi:1.3.1' + compile 'com.jakewharton.timber:timber:4.5.0' + compile 'com.jakewharton:butterknife:8.4.0' + annotationProcessor 'com.jakewharton:butterknife-compiler:8.4.0' + debugCompile 'com.facebook.stetho:stetho:1.4.1' + debugCompile 'com.facebook.stetho:stetho-okhttp3:1.4.1' + debugCompile 'com.squareup.leakcanary:leakcanary-android:1.5' + testCompile 'junit:junit:4.12' + testCompile 'com.google.truth:truth:0.30' + testCompile "org.mockito:mockito-core:1.10.19" + testCompile 'com.squareup.okhttp3:mockwebserver:3.5.0' + androidTestCompile "com.android.support.test.espresso:espresso-core:${espressoVersion}" + androidTestCompile "com.android.support.test.espresso:espresso-intents:${espressoVersion}" + androidTestCompile "com.android.support:support-annotations:${supportLibVersion}" + compile('com.crashlytics.sdk.android:crashlytics:2.6.6@aar') { + transitive = true + } +} diff --git a/app/gradle.properties b/app/gradle.properties new file mode 100644 index 0000000000..77dfa684bd --- /dev/null +++ b/app/gradle.properties @@ -0,0 +1,4 @@ +STORE_FILE=./path/to/key +STORE_PASSWORD=store_password +KEY_ALIAS=alias +KEY_PASSWORD=key_password diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000000..3b1635467d --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/proguard.cfg b/app/proguard.cfg new file mode 100644 index 0000000000..648485887b --- /dev/null +++ b/app/proguard.cfg @@ -0,0 +1,47 @@ +-ignorewarnings +-renamesourcefileattribute SourceFile +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature +-repackageclasses 'android.support.v7' + +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Application +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.view.View { public (android.content.Context); public (android.content.Context, android.util.AttributeSet); public (android.content.Context, android.util.AttributeSet, int); public void set*(...); } +-keep class * extends android.preference.DialogPreference { + public (android.content.Context); + public (android.content.Context, android.util.AttributeSet); + public (android.content.Context, android.util.AttributeSet, int); + public void set*(...); +} +-keepclassmembers class android.support.v4.app.Fragment { *** getActivity(); public *** onCreate(); public *** onCreateOptionsMenu(...); } + +# don't warn for okio errors +-dontwarn okio.** + +# rxjava +-dontwarn rx.** +-dontwarn sun.misc.** + +-keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* { + long producerIndex; + long consumerIndex; +} + +-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef { + long producerNode; + long consumerNode; +} + +# quran - needed to avoid a crash on 4.0.2/4.0.3/4.0.4 +-keep class com.quran.labs.androidquran.ui.util.ImageAyahUtils { *; } + +# moshi +-keep class com.squareup.moshi.** { *; } +-keep interface com.squareup.moshi.** { *; } +-dontwarn com.squareup.moshi.** +-keep public class com.quran.labs.androidquran.dao.** { *; } + +# retrolambda +-dontwarn java.lang.invoke.* diff --git a/app/src/androidTest/java/com/quran/labs/androidquran/BaseActivityTest.java b/app/src/androidTest/java/com/quran/labs/androidquran/BaseActivityTest.java new file mode 100644 index 0000000000..1ca0590ffb --- /dev/null +++ b/app/src/androidTest/java/com/quran/labs/androidquran/BaseActivityTest.java @@ -0,0 +1,34 @@ +package com.quran.labs.androidquran; + +import android.app.Activity; +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.intent.rule.IntentsTestRule; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public abstract class BaseActivityTest { + + @Rule + public IntentsTestRule rule; + + public BaseActivityTest(Class activityClass) { + resetData(); + rule = new IntentsTestRule<>(activityClass, true, false); + } + + private void resetData() { + File root = InstrumentationRegistry.getTargetContext().getFilesDir().getParentFile(); + String[] sharedPreferencesFileNames = new File(root, "shared_prefs").list(); + for (String fileName : sharedPreferencesFileNames) { + InstrumentationRegistry.getTargetContext() + .getSharedPreferences(fileName.replace(".xml", ""), Context.MODE_PRIVATE).edit().clear() + .apply(); + } + } +} diff --git a/app/src/androidTest/java/com/quran/labs/androidquran/ui/QuranActivityTest.java b/app/src/androidTest/java/com/quran/labs/androidquran/ui/QuranActivityTest.java new file mode 100644 index 0000000000..da07518893 --- /dev/null +++ b/app/src/androidTest/java/com/quran/labs/androidquran/ui/QuranActivityTest.java @@ -0,0 +1,46 @@ +package com.quran.labs.androidquran.ui; + +import com.quran.labs.androidquran.BaseActivityTest; + +import org.junit.Before; +import org.junit.Test; + +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.intent.Intents.intended; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static android.support.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.startsWith; + +public class QuranActivityTest extends BaseActivityTest { + + public QuranActivityTest() { + super(QuranActivity.class); + } + + @Before + public void setup() { + rule.launchActivity(null); + } + + @Test + public void testClickingOnSuraOnListViewNavigatesToSura() { + //given + onView(withText(startsWith("Quran"))) + .check(matches(isDisplayed())); + + //when + onView(allOf(withText("Surat Al-Fatihah"), isCompletelyDisplayed())) + .perform(click()); + + //then + intended(hasComponent(PagerActivity.class.getName())); + + onView(withText("Surat Al-Fatihah")) + .check(matches(isDisplayed())); + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..544bfb90a1 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/debug/java/com/quran/labs/androidquran/DebugApplication.java b/app/src/debug/java/com/quran/labs/androidquran/DebugApplication.java new file mode 100644 index 0000000000..974d19c48c --- /dev/null +++ b/app/src/debug/java/com/quran/labs/androidquran/DebugApplication.java @@ -0,0 +1,30 @@ +package com.quran.labs.androidquran; + +import com.facebook.stetho.Stetho; +import com.quran.labs.androidquran.component.application.ApplicationComponent; +import com.quran.labs.androidquran.component.application.DaggerDebugApplicationComponent; +import com.quran.labs.androidquran.module.application.ApplicationModule; +import com.squareup.leakcanary.LeakCanary; + +import timber.log.Timber; + +public class DebugApplication extends QuranApplication { + + @Override + public void onCreate() { + super.onCreate(); + + if (!LeakCanary.isInAnalyzerProcess(this)) { + Timber.plant(new Timber.DebugTree()); + Stetho.initializeWithDefaults(this); + LeakCanary.install(this); + } + } + + @Override + protected ApplicationComponent initializeInjector() { + return DaggerDebugApplicationComponent.builder() + .applicationModule(new ApplicationModule(this)) + .build(); + } +} diff --git a/app/src/debug/java/com/quran/labs/androidquran/component/application/DebugApplicationComponent.java b/app/src/debug/java/com/quran/labs/androidquran/component/application/DebugApplicationComponent.java new file mode 100644 index 0000000000..7dc227cdf6 --- /dev/null +++ b/app/src/debug/java/com/quran/labs/androidquran/component/application/DebugApplicationComponent.java @@ -0,0 +1,14 @@ +package com.quran.labs.androidquran.component.application; + +import com.quran.labs.androidquran.module.application.ApplicationModule; +import com.quran.labs.androidquran.module.application.DatabaseModule; +import com.quran.labs.androidquran.module.application.DebugNetworkModule; + +import javax.inject.Singleton; + +import dagger.Component; + +@Singleton +@Component(modules = { ApplicationModule.class, DatabaseModule.class, DebugNetworkModule.class } ) +interface DebugApplicationComponent extends ApplicationComponent { +} diff --git a/app/src/debug/java/com/quran/labs/androidquran/module/application/DebugNetworkModule.java b/app/src/debug/java/com/quran/labs/androidquran/module/application/DebugNetworkModule.java new file mode 100644 index 0000000000..e13eafb951 --- /dev/null +++ b/app/src/debug/java/com/quran/labs/androidquran/module/application/DebugNetworkModule.java @@ -0,0 +1,28 @@ +package com.quran.labs.androidquran.module.application; + +import com.facebook.stetho.okhttp3.StethoInterceptor; + +import java.util.concurrent.TimeUnit; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import okhttp3.OkHttpClient; + +@Module +public class DebugNetworkModule { + + private static final int DEFAULT_READ_TIMEOUT_SECONDS = 20; + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 15; + + @Provides + @Singleton + static OkHttpClient provideOkHttpClient() { + return new OkHttpClient.Builder() + .readTimeout(DEFAULT_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(DEFAULT_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .addNetworkInterceptor(new StethoInterceptor()) + .build(); + } +} diff --git a/app/src/debug/res/drawable/debug_icon.png b/app/src/debug/res/drawable/debug_icon.png new file mode 100644 index 0000000000..390b9fb059 Binary files /dev/null and b/app/src/debug/res/drawable/debug_icon.png differ diff --git a/app/src/madani/assets/uthmanic_hafs_ver09.otf b/app/src/madani/assets/uthmanic_hafs_ver09.otf new file mode 100644 index 0000000000..73855caa20 Binary files /dev/null and b/app/src/madani/assets/uthmanic_hafs_ver09.otf differ diff --git a/app/src/madani/java/com/quran/labs/androidquran/data/QuranConstants.java b/app/src/madani/java/com/quran/labs/androidquran/data/QuranConstants.java new file mode 100644 index 0000000000..51f4ac54f9 --- /dev/null +++ b/app/src/madani/java/com/quran/labs/androidquran/data/QuranConstants.java @@ -0,0 +1,14 @@ +package com.quran.labs.androidquran.data; + +import com.quran.labs.androidquran.util.QuranScreenInfo; + +import android.support.annotation.NonNull; +import android.view.Display; + +public class QuranConstants { + public static final int NUMBER_OF_PAGES = 604; + + public static QuranScreenInfo.PageProvider getPageProvider(@NonNull Display display) { + return new QuranScreenInfo.DefaultPageProvider(display); + } +} diff --git a/app/src/madani/java/com/quran/labs/androidquran/data/QuranData.java b/app/src/madani/java/com/quran/labs/androidquran/data/QuranData.java new file mode 100644 index 0000000000..8e32512b08 --- /dev/null +++ b/app/src/madani/java/com/quran/labs/androidquran/data/QuranData.java @@ -0,0 +1,5 @@ +package com.quran.labs.androidquran.data; + +public class QuranData extends BaseQuranData { + +} diff --git a/app/src/madani/java/com/quran/labs/androidquran/data/QuranFileConstants.java b/app/src/madani/java/com/quran/labs/androidquran/data/QuranFileConstants.java new file mode 100644 index 0000000000..be9ae8c481 --- /dev/null +++ b/app/src/madani/java/com/quran/labs/androidquran/data/QuranFileConstants.java @@ -0,0 +1,28 @@ +package com.quran.labs.androidquran.data; + +import com.quran.labs.androidquran.ui.util.TypefaceManager; + +public class QuranFileConstants { + // server urls + public static final String BASE_HOST = "http://android.quran.com/data/"; + public static final String IMG_BASE_URL = BASE_HOST; + public static final String IMG_ZIP_BASE_URL = BASE_HOST + "zips/"; + public static final String PATCH_ZIP_BASE_URL = BASE_HOST + "patches/v"; + public static final String DATABASE_BASE_URL = BASE_HOST + "databases/"; + public static final String AYAHINFO_BASE_URL = BASE_HOST + "databases/ayahinfo/"; + public static final String AUDIO_DB_BASE_URL = DATABASE_BASE_URL + "audio/"; + public static final int FONT_TYPE = TypefaceManager.TYPE_UTHMANI_HAFS; + + // local paths + public static final String QURAN_BASE = "quran_android/"; + public static final String DATABASE_DIRECTORY = "databases"; + public static final String AUDIO_DIRECTORY = "audio"; + public static final String AYAHINFO_DIRECTORY = DATABASE_DIRECTORY; + public static final String IMAGES_DIRECTORY = ""; + + // arabic database + public static final String ARABIC_DATABASE = "quran.ar.db"; + + // images version + public static final int IMAGES_VERSION = 5; +} diff --git a/app/src/madani/java/com/quran/labs/androidquran/data/QuranInfo.java b/app/src/madani/java/com/quran/labs/androidquran/data/QuranInfo.java new file mode 100644 index 0000000000..81c1d4a570 --- /dev/null +++ b/app/src/madani/java/com/quran/labs/androidquran/data/QuranInfo.java @@ -0,0 +1,4 @@ +package com.quran.labs.androidquran.data; + +public class QuranInfo extends BaseQuranInfo { +} diff --git a/res/drawable/icon.png b/app/src/madani/res/drawable-hdpi/icon.png similarity index 100% rename from res/drawable/icon.png rename to app/src/madani/res/drawable-hdpi/icon.png diff --git a/app/src/madani/res/drawable-mdpi/icon.png b/app/src/madani/res/drawable-mdpi/icon.png new file mode 100644 index 0000000000..863060d542 Binary files /dev/null and b/app/src/madani/res/drawable-mdpi/icon.png differ diff --git a/app/src/madani/res/drawable-xhdpi/icon.png b/app/src/madani/res/drawable-xhdpi/icon.png new file mode 100644 index 0000000000..206497a18e Binary files /dev/null and b/app/src/madani/res/drawable-xhdpi/icon.png differ diff --git a/app/src/madani/res/drawable-xxhdpi/icon.png b/app/src/madani/res/drawable-xxhdpi/icon.png new file mode 100644 index 0000000000..0d6cd17a3d Binary files /dev/null and b/app/src/madani/res/drawable-xxhdpi/icon.png differ diff --git a/app/src/madani/res/drawable-xxxhdpi/icon.png b/app/src/madani/res/drawable-xxxhdpi/icon.png new file mode 100644 index 0000000000..2d370a1e78 Binary files /dev/null and b/app/src/madani/res/drawable-xxxhdpi/icon.png differ diff --git a/app/src/madani/res/xml/shortcuts.xml b/app/src/madani/res/xml/shortcuts.xml new file mode 100644 index 0000000000..669ade1750 --- /dev/null +++ b/app/src/madani/res/xml/shortcuts.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..121e95ff87 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/android/support/v4/view/NonRestoringViewPager.java b/app/src/main/java/android/support/v4/view/NonRestoringViewPager.java new file mode 100644 index 0000000000..5397066a70 --- /dev/null +++ b/app/src/main/java/android/support/v4/view/NonRestoringViewPager.java @@ -0,0 +1,75 @@ +package android.support.v4.view; + +import android.content.Context; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranUtils; + +/** + * NonRestoringViewPager is a hack to sometimes prevent ViewPager from restoring its + * page in onRestoreInstanceState. This is done because in some cases, the ViewPager + * has a different number of pages in portrait and landscape, and so restoring the + * old page number is incorrect (and requires making 3 fragments that will be thrown + * away shortly thereafter). + * + * It also contains the same swallowing of an Exception that is occasionally thrown + * on certain devices in onTouchEvent (this hack is also present in QuranViewPager). + * + * Note that the package name for this is a hack to allow overriding setCurrentItemInternal, + * which is package protected, so as to be able to ignore the initial "restore page" event + * without ignoring the entirety of saving and restoring of state. + * + * Hopefully, this is a short term solution. Note that an alternative solution that also + * works is to handle our own SavedState here (and call super during save, but ignore the + * result, and call super during restore with null). This, however, seemed a bit cleaner + * for now, since we still want to get save and restore on the PagerAdapter. + */ +public class NonRestoringViewPager extends ViewPager { + private boolean isRestoring = false; + private final boolean useDefaultImplementation; + + public NonRestoringViewPager(Context context) { + super(context); + useDefaultImplementation = + !QuranUtils.isDualPagesInLandscape(context, QuranScreenInfo.getOrMakeInstance(context)); + } + + public NonRestoringViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + useDefaultImplementation = + !QuranUtils.isDualPagesInLandscape(context, QuranScreenInfo.getOrMakeInstance(context)); + } + + @Override + void setCurrentItemInternal(int item, boolean smoothScroll, boolean always) { + if (useDefaultImplementation || !isRestoring) { + super.setCurrentItemInternal(item, smoothScroll, always); + } + } + + @Override + void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) { + if (useDefaultImplementation || !isRestoring) { + super.setCurrentItemInternal(item, smoothScroll, always, velocity); + } + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + isRestoring = true; + super.onRestoreInstanceState(state); + isRestoring = false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + try { + return super.onTouchEvent(ev); + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/AboutUsActivity.java b/app/src/main/java/com/quran/labs/androidquran/AboutUsActivity.java new file mode 100644 index 0000000000..ec8f327e22 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/AboutUsActivity.java @@ -0,0 +1,45 @@ +package com.quran.labs.androidquran; + +import com.quran.labs.androidquran.ui.QuranActionBarActivity; +import com.quran.labs.androidquran.ui.fragment.AboutFragment; + +import android.app.Fragment; +import android.app.FragmentManager; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.view.MenuItem; + +public class AboutUsActivity extends QuranActionBarActivity { + + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.about_us); + + final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.setTitle(R.string.menu_about); + setSupportActionBar(toolbar); + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + + final FragmentManager fm = getFragmentManager(); + final Fragment fragment = fm.findFragmentById(R.id.content); + if (fragment == null) { + fm.beginTransaction() + .replace(R.id.content, new AboutFragment()) + .commit(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return false; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/HelpActivity.java b/app/src/main/java/com/quran/labs/androidquran/HelpActivity.java new file mode 100644 index 0000000000..0d603c7f03 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/HelpActivity.java @@ -0,0 +1,31 @@ +package com.quran.labs.androidquran; + +import com.quran.labs.androidquran.ui.QuranActionBarActivity; + +import android.os.Bundle; +import android.text.Html; +import android.view.MenuItem; +import android.widget.TextView; + +public class HelpActivity extends QuranActionBarActivity { + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayShowHomeEnabled(true); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setContentView(R.layout.help); + + TextView helpText = (TextView) findViewById(R.id.txtHelp); + helpText.setText(Html.fromHtml(getString(R.string.help))); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return false; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/QuranAdvancedPreferenceActivity.java b/app/src/main/java/com/quran/labs/androidquran/QuranAdvancedPreferenceActivity.java new file mode 100644 index 0000000000..31d0872698 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/QuranAdvancedPreferenceActivity.java @@ -0,0 +1,104 @@ +package com.quran.labs.androidquran; + +import android.Manifest; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.view.MenuItem; +import android.widget.Toast; + +import com.quran.labs.androidquran.service.util.PermissionUtil; +import com.quran.labs.androidquran.ui.QuranActionBarActivity; +import com.quran.labs.androidquran.ui.fragment.QuranAdvancedSettingsFragment; +import com.quran.labs.androidquran.util.AudioManagerUtils; +import com.quran.labs.androidquran.util.QuranSettings; + +public class QuranAdvancedPreferenceActivity extends QuranActionBarActivity { + + private static final String SI_LOCATION_TO_WRITE = "SI_LOCATION_TO_WRITE"; + private static final int REQUEST_WRITE_TO_SDCARD_PERMISSION = 1; + + private String mLocationToWrite; + + @Override + protected void onCreate(Bundle savedInstanceState) { + ((QuranApplication) getApplication()).refreshLocale(this, false); + super.onCreate(savedInstanceState); + setContentView(R.layout.preferences); + + final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.setTitle(R.string.prefs_category_advanced); + setSupportActionBar(toolbar); + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + + AudioManagerUtils.clearCache(); + + if (savedInstanceState != null) { + mLocationToWrite = savedInstanceState.getString(SI_LOCATION_TO_WRITE); + } + + final FragmentManager fm = getFragmentManager(); + final Fragment fragment = fm.findFragmentById(R.id.content); + if (fragment == null) { + fm.beginTransaction() + .replace(R.id.content, new QuranAdvancedSettingsFragment()) + .commit(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + if (mLocationToWrite != null) { + outState.putString(SI_LOCATION_TO_WRITE, mLocationToWrite); + } + super.onSaveInstanceState(outState); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + public void requestWriteExternalSdcardPermission(String newLocation) { + if (PermissionUtil.canRequestWriteExternalStoragePermission(this)) { + QuranSettings.getInstance(this).setSdcardPermissionsDialogPresented(); + + mLocationToWrite = newLocation; + ActivityCompat.requestPermissions(this, + new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE }, + REQUEST_WRITE_TO_SDCARD_PERMISSION); + } else { + // in the future, we should make this a direct link - perhaps using a Snackbar. + Toast.makeText(this, R.string.please_grant_permissions, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == REQUEST_WRITE_TO_SDCARD_PERMISSION) { + if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (mLocationToWrite != null) { + Fragment fragment = getFragmentManager().findFragmentById(R.id.content); + if (fragment instanceof QuranAdvancedSettingsFragment) { + ((QuranAdvancedSettingsFragment) fragment).moveFiles(mLocationToWrite); + } + + } + } + mLocationToWrite = null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/QuranApplication.java b/app/src/main/java/com/quran/labs/androidquran/QuranApplication.java new file mode 100644 index 0000000000..ffc6120f29 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/QuranApplication.java @@ -0,0 +1,78 @@ +package com.quran.labs.androidquran; + +import android.app.Application; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.support.annotation.NonNull; + +import com.crashlytics.android.Crashlytics; +import com.crashlytics.android.core.CrashlyticsCore; +import com.quran.labs.androidquran.component.application.DaggerApplicationComponent; +import com.quran.labs.androidquran.component.application.ApplicationComponent; +import com.quran.labs.androidquran.module.application.ApplicationModule; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.RecordingLogTree; + +import java.util.Locale; + +import io.fabric.sdk.android.Fabric; +import timber.log.Timber; + +public class QuranApplication extends Application { + private ApplicationComponent applicationComponent; + + @Override + public void onCreate() { + super.onCreate(); + Fabric.with(this, new Crashlytics.Builder() + .core(new CrashlyticsCore.Builder() + .disabled(BuildConfig.DEBUG) + .build()) + .build()); + Timber.plant(new RecordingLogTree()); + this.applicationComponent = initializeInjector(); + } + + protected ApplicationComponent initializeInjector() { + return DaggerApplicationComponent.builder() + .applicationModule(new ApplicationModule(this)) + .build(); + } + + public ApplicationComponent getApplicationComponent() { + return this.applicationComponent; + } + + public void refreshLocale(@NonNull Context context, boolean force) { + final String language = QuranSettings.getInstance(this).isArabicNames() ? "ar" : null; + + final Locale locale; + if ("ar".equals(language)) { + locale = new Locale("ar"); + } else if (force) { + // get the system locale (since we overwrote the default locale) + locale = Resources.getSystem().getConfiguration().locale; + } else { + // nothing to do... + return; + } + + updateLocale(context, locale); + final Context appContext = context.getApplicationContext(); + if (context != appContext) { + updateLocale(appContext, locale); + } + } + + private void updateLocale(@NonNull Context context, @NonNull Locale locale) { + final Resources resources = context.getResources(); + Configuration config = resources.getConfiguration(); + config.locale = locale; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + config.setLayoutDirection(config.locale); + } + resources.updateConfiguration(config, resources.getDisplayMetrics()); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/QuranDataActivity.java b/app/src/main/java/com/quran/labs/androidquran/QuranDataActivity.java new file mode 100644 index 0000000000..d66549aa63 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/QuranDataActivity.java @@ -0,0 +1,537 @@ +package com.quran.labs.androidquran; + +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.CustomEvent; +import com.quran.labs.androidquran.data.QuranFileConstants; +import com.quran.labs.androidquran.service.QuranDownloadService; +import com.quran.labs.androidquran.service.util.DefaultDownloadReceiver; +import com.quran.labs.androidquran.service.util.PermissionUtil; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.ServiceIntentHelper; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranSettings; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.AlertDialog; +import android.text.TextUtils; +import android.widget.Toast; + +import java.io.File; + +import timber.log.Timber; + +public class QuranDataActivity extends Activity implements + DefaultDownloadReceiver.SimpleDownloadListener, + ActivityCompat.OnRequestPermissionsResultCallback { + + public static final String PAGES_DOWNLOAD_KEY = "PAGES_DOWNLOAD_KEY"; + + private static final int LATEST_IMAGE_VERSION = QuranFileConstants.IMAGES_VERSION; + private static final int REQUEST_WRITE_TO_SDCARD_PERMISSIONS = 1; + + private boolean mIsPaused = false; + private AsyncTask mCheckPagesTask; + private QuranSettings mQuranSettings; + private AlertDialog mErrorDialog = null; + private AlertDialog mPromptForDownloadDialog = null; + private AlertDialog mPermissionsDialog; + private DefaultDownloadReceiver mDownloadReceiver = null; + private boolean mNeedPortraitImages = false; + private boolean mNeedLandscapeImages = false; + private boolean mTaskIsRunning; + private String mPatchUrl; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + QuranScreenInfo.getOrMakeInstance(this); + mQuranSettings = QuranSettings.getInstance(this); + mQuranSettings.upgradePreferences(); + } + + @Override + protected void onResume() { + super.onResume(); + + mIsPaused = false; + mDownloadReceiver = new DefaultDownloadReceiver(this, + QuranDownloadService.DOWNLOAD_TYPE_PAGES); + mDownloadReceiver.setCanCancelDownload(true); + String action = QuranDownloadNotifier.ProgressIntent.INTENT_NAME; + LocalBroadcastManager.getInstance(this).registerReceiver( + mDownloadReceiver, + new IntentFilter(action)); + mDownloadReceiver.setListener(this); + checkPermissions(); + } + + @Override + protected void onPause() { + mIsPaused = true; + if (mDownloadReceiver != null) { + mDownloadReceiver.setListener(null); + LocalBroadcastManager.getInstance(this). + unregisterReceiver(mDownloadReceiver); + mDownloadReceiver = null; + } + + if (mPromptForDownloadDialog != null) { + mPromptForDownloadDialog.dismiss(); + mPromptForDownloadDialog = null; + } + + if (mErrorDialog != null) { + mErrorDialog.dismiss(); + mErrorDialog = null; + } + + super.onPause(); + } + + private void checkPermissions() { + final String path = mQuranSettings.getAppCustomLocation(); + final File fallbackFile = getExternalFilesDir(null); + + boolean usesExternalFileDir = path != null && path.contains("com.quran"); + if (path == null || usesExternalFileDir && fallbackFile == null) { + // suggests that we're on m+ and getExternalFilesDir returned null at some point + runListView(); + return; + } + + boolean needsPermission = !usesExternalFileDir || !path.equals(fallbackFile.getAbsolutePath()); + if (needsPermission && !PermissionUtil.haveWriteExternalStoragePermission(this)) { + // request permission + if (PermissionUtil.canRequestWriteExternalStoragePermission(this)) { + Answers.getInstance().logCustom(new CustomEvent("storagePermissionRationaleShown")); + //show permission rationale dialog + mPermissionsDialog = new AlertDialog.Builder(this) + .setMessage(R.string.storage_permission_rationale) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + mPermissionsDialog = null; + + Answers.getInstance().logCustom( + new CustomEvent("storagePermissionRationaleAccepted")); + // request permissions + requestExternalSdcardPermission(); + } + }) + .setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // dismiss the dialog + dialog.dismiss(); + mPermissionsDialog = null; + + Answers.getInstance().logCustom( + new CustomEvent("storagePermissionRationaleDenied")); + // fall back if we can + if (fallbackFile != null) { + mQuranSettings.setAppCustomLocation(fallbackFile.getAbsolutePath()); + checkPages(); + } else { + // set to null so we can try again next launch + mQuranSettings.setAppCustomLocation(null); + runListView(); + } + } + }) + .create(); + mPermissionsDialog.show(); + } else { + // fall back if we can + if (fallbackFile != null) { + mQuranSettings.setAppCustomLocation(fallbackFile.getAbsolutePath()); + checkPages(); + } else { + // set to null so we can try again next launch + mQuranSettings.setAppCustomLocation(null); + runListView(); + } + } + } else { + checkPages(); + } + } + + private void checkPages() { + if (mTaskIsRunning) { + return; + } + + mTaskIsRunning = true; + + // check whether or not we need to download + mCheckPagesTask = new CheckPagesAsyncTask(this); + mCheckPagesTask.execute(); + } + + private void requestExternalSdcardPermission() { + ActivityCompat.requestPermissions(this, + new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE }, + REQUEST_WRITE_TO_SDCARD_PERMISSIONS); + mQuranSettings.setSdcardPermissionsDialogPresented(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == REQUEST_WRITE_TO_SDCARD_PERMISSIONS) { + if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + /** + * taking a risk here. on nexus 6, the permission is granted automatically. on the emulator, + * a restart is required. on reddit, someone with a nexus 9 said they also didn't need to + * restart for the permission to take effect. + * + * going to assume that it just works to avoid meh code (a check to see if i can actually + * write, and a PendingIntent plus System.exit to restart the app otherwise). logging to + * know if we should actually have that code or not. + * + * also see: + * http://stackoverflow.com/questions/32471888/ + */ + Answers.getInstance().logCustom(new CustomEvent("storagePermissionGranted")); + if (!canWriteSdcardAfterPermissions()) { + Answers.getInstance().logCustom(new CustomEvent("storagePermissionNeedsRestart")); + Toast.makeText(this, + R.string.storage_permission_please_restart, Toast.LENGTH_LONG).show(); + } + checkPages(); + } else { + Answers.getInstance().logCustom(new CustomEvent("storagePermissionDenied")); + final File fallbackFile = getExternalFilesDir(null); + if (fallbackFile != null) { + mQuranSettings.setAppCustomLocation(fallbackFile.getAbsolutePath()); + checkPages(); + } else { + // set to null so we can try again next launch + mQuranSettings.setAppCustomLocation(null); + runListView(); + } + } + } + } + + private boolean canWriteSdcardAfterPermissions() { + String location = QuranFileUtils.getQuranBaseDirectory(this); + if (location != null) { + try { + if (new File(location).exists() || QuranFileUtils.makeQuranDirectory(this)) { + File f = new File(location, "" + System.currentTimeMillis()); + if (f.createNewFile()) { + f.delete(); + return true; + } + } + } catch (Exception e) { + // no op + } + } + return false; + } + + @Override + public void handleDownloadSuccess() { + mQuranSettings.removeShouldFetchPages(); + runListView(); + } + + @Override + public void handleDownloadFailure(int errId) { + if (mErrorDialog != null && mErrorDialog.isShowing()) { + return; + } + + showFatalErrorDialog(errId); + } + + private void showFatalErrorDialog(int errorId) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(errorId); + builder.setCancelable(false); + builder.setPositiveButton(R.string.download_retry, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.dismiss(); + mErrorDialog = null; + removeErrorPreferences(); + downloadQuranImages(true); + } + }); + + builder.setNegativeButton(R.string.download_cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + mErrorDialog = null; + removeErrorPreferences(); + mQuranSettings.setShouldFetchPages(false); + runListView(); + } + }); + + mErrorDialog = builder.create(); + mErrorDialog.show(); + } + + private void removeErrorPreferences() { + mQuranSettings.clearLastDownloadError(); + } + + class CheckPagesAsyncTask extends AsyncTask { + + private final Context mAppContext; + private String mPatchParam; + private boolean mStorageNotAvailable; + + public CheckPagesAsyncTask(Context context) { + mAppContext = context.getApplicationContext(); + } + + @Override + protected Boolean doInBackground(Void... params) { + final String baseDir = QuranFileUtils.getQuranBaseDirectory(mAppContext); + if (baseDir == null) { + mStorageNotAvailable = true; + return false; + } + + final QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + if (!mQuranSettings.haveDefaultImagesDirectory()) { + /* previously, we would send any screen widths greater than 1280 + * to get 1920 images. this was problematic for various reasons, + * including: + * a. a texture limit for the maximum size of a bitmap that could + * be loaded, which the 1920x3106 images exceeded on devices + * with the minimum 2048 height capacity. + * b. slow to switch pages due to the massive size of the gl + * texture loaded by android. + * + * consequently, in this new version, we make anything above 1024 + * fallback to a 1260 bucket (height of 2038). this works around + * both problems (much faster page flipping now too) with a very + * minor loss in quality. + * + * this code checks and sees, if the user already has a complete + * folder of images - 1920, then 1280, then 1024 - and in any of + * those cases, sets that in the pref so we load those instead of + * the new 1260 images. + */ + final String fallback = + QuranFileUtils.getPotentialFallbackDirectory(mAppContext); + if (fallback != null) { + mQuranSettings.setDefaultImagesDirectory(fallback); + qsi.setOverrideParam(fallback); + } + } + + final String width = qsi.getWidthParam(); + if (qsi.isTablet(mAppContext)) { + final String tabletWidth = qsi.getTabletWidthParam(); + boolean haveLandscape = QuranFileUtils.haveAllImages(mAppContext, tabletWidth); + boolean havePortrait = QuranFileUtils.haveAllImages(mAppContext, width); + mNeedPortraitImages = !havePortrait; + mNeedLandscapeImages = !haveLandscape; + Timber.d("checkPages: have portrait images: %s, have landscape images: %s", + havePortrait ? "yes" : "no", haveLandscape ? "yes" : "no"); + if (haveLandscape && havePortrait) { + // if we have the images, see if we need a patch set or not + if (!QuranFileUtils.isVersion(mAppContext, width, LATEST_IMAGE_VERSION) || + !QuranFileUtils.isVersion(mAppContext, tabletWidth, LATEST_IMAGE_VERSION)) { + if (!width.equals(tabletWidth)) { + mPatchParam = width + tabletWidth; + } else { + mPatchParam = width; + } + } + } + return haveLandscape && havePortrait; + } else { + boolean haveAll = QuranFileUtils.haveAllImages(mAppContext, + QuranScreenInfo.getInstance().getWidthParam()); + Timber.d("checkPages: have all images: %s", haveAll ? "yes" : "no"); + mNeedPortraitImages = !haveAll; + mNeedLandscapeImages = false; + if (haveAll && !QuranFileUtils.isVersion(mAppContext, width, LATEST_IMAGE_VERSION)) { + mPatchParam = width; + } + return haveAll; + } + } + + @Override + protected void onPostExecute(@NonNull Boolean result) { + mCheckPagesTask = null; + mPatchUrl = null; + mTaskIsRunning = false; + + if (mIsPaused) { + return; + } + + if (!result) { + if (mStorageNotAvailable) { + // no storage mounted, nothing we can do... + runListView(); + return; + } + + String lastErrorItem = mQuranSettings.getLastDownloadItemWithError(); + Timber.d("checkPages: need to download pages... lastError: %s", lastErrorItem); + if (PAGES_DOWNLOAD_KEY.equals(lastErrorItem)) { + int lastError = mQuranSettings.getLastDownloadErrorCode(); + int errorId = ServiceIntentHelper + .getErrorResourceFromErrorCode(lastError, false); + showFatalErrorDialog(errorId); + } else if (mQuranSettings.shouldFetchPages()) { + downloadQuranImages(false); + } else { + promptForDownload(); + } + } else { + if (!TextUtils.isEmpty(mPatchParam)) { + Timber.d("checkPages: have pages, but need patch %s", mPatchParam); + mPatchUrl = QuranFileUtils.getPatchFileUrl(mPatchParam, LATEST_IMAGE_VERSION); + promptForDownload(); + return; + } + runListView(); + } + } + } + + /** + * this method asks the service to download quran images. + * + * there are two possible cases - the first is one in which we are not + * sure if a download is going on or not (ie we just came in the app, + * the files aren't all there, so we want to start downloading). in + * this case, we start the download only if we didn't receive any + * broadcasts before starting it. + * + * in the second case, we know what we are doing (either because the user + * just clicked "download" for the first time or the user asked to retry + * after an error), then we pass the force parameter, which asks the + * service to just restart the download irrespective of anything else. + * + * @param force whether to force the download to restart or not + */ + private void downloadQuranImages(boolean force) { + // if any broadcasts were received, then we are already downloading + // so unless we know what we are doing (via force), don't ask the + // service to restart the download + if (mDownloadReceiver != null && + mDownloadReceiver.didReceiveBroadcast() && !force) { + return; + } + if (mIsPaused) { + return; + } + + QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + + String url; + if (mNeedPortraitImages && !mNeedLandscapeImages) { + // phone (and tablet when upgrading on some devices, ex n10) + url = QuranFileUtils.getZipFileUrl(); + } else if (mNeedLandscapeImages && !mNeedPortraitImages) { + // tablet (when upgrading from pre-tablet on some devices, ex n7). + url = QuranFileUtils.getZipFileUrl(qsi.getTabletWidthParam()); + } else { + // new tablet installation - if both image sets are the same + // size, then just get the correct one only + if (qsi.getTabletWidthParam().equals(qsi.getWidthParam())) { + url = QuranFileUtils.getZipFileUrl(); + } else { + // otherwise download one zip with both image sets + String widthParam = qsi.getWidthParam() + + qsi.getTabletWidthParam(); + url = QuranFileUtils.getZipFileUrl(widthParam); + } + } + + // if we have a patch url, just use that + if (!TextUtils.isEmpty(mPatchUrl)) { + url = mPatchUrl; + } + + String destination = QuranFileUtils.getQuranImagesBaseDirectory(QuranDataActivity.this); + + // start service + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + destination, getString(R.string.app_name), PAGES_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_PAGES); + + if (!force) { + // handle race condition in which we missed the error preference and + // the broadcast - if so, just rebroadcast errors so we handle them + intent.putExtra(QuranDownloadService.EXTRA_REPEAT_LAST_ERROR, true); + } + + startService(intent); + } + + private void promptForDownload() { + int message = R.string.downloadPrompt; + if (QuranScreenInfo.getInstance().isTablet(this) && + (mNeedPortraitImages != mNeedLandscapeImages)) { + message = R.string.downloadTabletPrompt; + } + + if (!TextUtils.isEmpty(mPatchUrl)) { + // patch message if applicable + message = R.string.downloadImportantPrompt; + } + + AlertDialog.Builder dialog = new AlertDialog.Builder(this); + dialog.setMessage(message); + dialog.setCancelable(false); + dialog.setPositiveButton(R.string.downloadPrompt_ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.dismiss(); + mPromptForDownloadDialog = null; + mQuranSettings.setShouldFetchPages(true); + downloadQuranImages(true); + } + }); + + dialog.setNegativeButton(R.string.downloadPrompt_no, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.dismiss(); + mPromptForDownloadDialog = null; + runListView(); + } + }); + + mPromptForDownloadDialog = dialog.create(); + mPromptForDownloadDialog.setTitle(R.string.downloadPrompt_title); + mPromptForDownloadDialog.show(); + } + + protected void runListView() { + Intent i = new Intent(this, QuranActivity.class); + i.putExtra(QuranActivity.EXTRA_SHOW_TRANSLATION_UPGRADE, mQuranSettings.haveUpdatedTranslations()); + startActivity(i); + finish(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/QuranForwarderActivity.java b/app/src/main/java/com/quran/labs/androidquran/QuranForwarderActivity.java new file mode 100644 index 0000000000..b6fb2f5cb8 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/QuranForwarderActivity.java @@ -0,0 +1,47 @@ +package com.quran.labs.androidquran; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.ui.PagerActivity; + +public class QuranForwarderActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // handle urls of type quran://sura/ayah + Intent intent = getIntent(); + if (intent != null){ + Uri data = intent.getData(); + if (data != null){ + String urlString = data.toString(); + String[] pieces = urlString.split("/"); + + Integer sura = null; + int ayah = 1; + for (String s : pieces){ + try { + int i = Integer.parseInt(s); + if (sura == null){ sura = i; } + else { ayah = i; break; } + } + catch (Exception e){} + } + + if (sura != null){ + int page = QuranInfo.getPageFromSuraAyah(sura, ayah); + Intent showSuraIntent = new Intent(this, PagerActivity.class); + showSuraIntent.putExtra("page", page); + showSuraIntent.putExtra(PagerActivity.EXTRA_HIGHLIGHT_SURA, sura); + showSuraIntent.putExtra(PagerActivity.EXTRA_HIGHLIGHT_AYAH, ayah); + startActivity(showSuraIntent); + } + } + } + finish(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/QuranImportActivity.java b/app/src/main/java/com/quran/labs/androidquran/QuranImportActivity.java new file mode 100644 index 0000000000..5199de3dd1 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/QuranImportActivity.java @@ -0,0 +1,97 @@ +package com.quran.labs.androidquran; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.ActivityCompat; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.widget.Toast; + +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.CustomEvent; +import com.quran.labs.androidquran.dao.BookmarkData; +import com.quran.labs.androidquran.presenter.QuranImportPresenter; + +import javax.inject.Inject; + +public class QuranImportActivity extends AppCompatActivity implements + ActivityCompat.OnRequestPermissionsResultCallback { + private AlertDialog mDialog; + @Inject QuranImportPresenter mPresenter; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + QuranApplication quranApp = ((QuranApplication) getApplication()); + quranApp.refreshLocale(this, false); + super.onCreate(savedInstanceState); + quranApp.getApplicationComponent().inject(this); + Answers.getInstance().logCustom(new CustomEvent("importData")); + } + + @Override + protected void onPause() { + mPresenter.unbind(this); + super.onPause(); + } + + @Override + protected void onResume() { + super.onResume(); + mPresenter.bind(this); + } + + @Override + protected void onDestroy() { + mPresenter.unbind(this); + if (mDialog != null) { + mDialog.dismiss(); + } + super.onDestroy(); + } + + public boolean isShowingDialog() { + return mDialog != null; + } + + public void showImportConfirmationDialog(final BookmarkData bookmarkData) { + String message = getString(R.string.import_data_and_override, + bookmarkData.getBookmarks().size(), + bookmarkData.getTags().size()); + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(R.string.import_data, + (dialog, which) -> mPresenter.importData(bookmarkData)) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> finish()) + .setOnCancelListener(dialog -> finish()); + mDialog = builder.show(); + } + + public void showImportComplete() { + Answers.getInstance().logCustom(new CustomEvent("importDataSuccessful")); + Toast.makeText(QuranImportActivity.this, + R.string.import_successful, Toast.LENGTH_LONG).show(); + finish(); + } + + public void showError() { + showErrorInternal(R.string.import_data_error); + } + + public void showPermissionsError() { + showErrorInternal(R.string.import_data_permissions_error); + } + + private void showErrorInternal(int messageId) { + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setMessage(messageId) + .setPositiveButton(android.R.string.ok, (dialog, which) -> finish()); + mDialog = builder.show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + mPresenter.onPermissionsResult(requestCode, grantResults); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/QuranPreferenceActivity.java b/app/src/main/java/com/quran/labs/androidquran/QuranPreferenceActivity.java new file mode 100644 index 0000000000..5d70e4c958 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/QuranPreferenceActivity.java @@ -0,0 +1,65 @@ +package com.quran.labs.androidquran; + +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.view.MenuItem; + +import com.quran.labs.androidquran.ui.QuranActionBarActivity; +import com.quran.labs.androidquran.ui.fragment.QuranSettingsFragment; +import com.quran.labs.androidquran.util.AudioManagerUtils; + +public class QuranPreferenceActivity extends QuranActionBarActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + ((QuranApplication) getApplication()).refreshLocale(this, false); + super.onCreate(savedInstanceState); + setContentView(R.layout.preferences); + + final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.setTitle(R.string.menu_settings); + setSupportActionBar(toolbar); + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + + AudioManagerUtils.clearCache(); + + + final FragmentManager fm = getFragmentManager(); + final Fragment fragment = fm.findFragmentById(R.id.content); + if (fragment == null) { + fm.beginTransaction() + .replace(R.id.content, new QuranSettingsFragment()) + .commit(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + + super.onSaveInstanceState(outState); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + public void restartActivity() { + ((QuranApplication) getApplication()).refreshLocale(this, true); + Intent intent = getIntent(); + finish(); + startActivity(intent); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/SearchActivity.java b/app/src/main/java/com/quran/labs/androidquran/SearchActivity.java new file mode 100644 index 0000000000..4ce6dcd6bb --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/SearchActivity.java @@ -0,0 +1,407 @@ +package com.quran.labs.androidquran; + +import android.app.SearchManager; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.StringRes; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.content.LocalBroadcastManager; +import android.text.Html; +import android.text.SpannableString; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.CursorAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import com.quran.labs.androidquran.data.QuranDataProvider; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.service.QuranDownloadService; +import com.quran.labs.androidquran.service.util.DefaultDownloadReceiver; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.ServiceIntentHelper; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.QuranActionBarActivity; +import com.quran.labs.androidquran.ui.TranslationManagerActivity; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; + +public class SearchActivity extends QuranActionBarActivity + implements DefaultDownloadReceiver.SimpleDownloadListener, + LoaderManager.LoaderCallbacks { + + public static final String SEARCH_INFO_DOWNLOAD_KEY = "SEARCH_INFO_DOWNLOAD_KEY"; + private static final String EXTRA_QUERY = "EXTRA_QUERY"; + + private TextView messageView; + private TextView warningView; + private Button buttonGetTranslations; + private boolean downloadArabicSearchDb; + private boolean isArabicSearch; + private String query; + private ResultAdapter adapter; + private DefaultDownloadReceiver downloadReceiver; + private CheckBox chkBoxAdvanceSearch; + private boolean isAdvancedSearch; + private Button submitAdvancedSearch; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.search); + messageView = (TextView) findViewById(R.id.search_area); + warningView = (TextView) findViewById(R.id.search_warning); + buttonGetTranslations = (Button) findViewById(R.id.btnGetTranslations); + chkBoxAdvanceSearch = (CheckBox) findViewById(R.id.chkBoxAdvSearch); + submitAdvancedSearch = (Button) findViewById(R.id.buttonAdvanceSearch); + + chkBoxAdvanceSearch.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if(isAdvancedSearch) + showHideAdvanceSearchLayout(View.VISIBLE); + + } + }); + chkBoxAdvanceSearch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if(isChecked) { + isAdvancedSearch = true; + showHideAdvanceSearchLayout(View.VISIBLE); + } + else{ + isAdvancedSearch = false; + showHideAdvanceSearchLayout(View.GONE); + } + + } + }); + + submitAdvancedSearch.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + String query = getIntent().getStringExtra(SearchManager.QUERY); + EditText andEditText = (EditText) findViewById(R.id.editTextAnd); + EditText orEditText = (EditText) findViewById(R.id.editTextOr); + EditText notEditText = (EditText) findViewById(R.id.editTextNot); + String q1 = andEditText.getText().toString(); + String q2 = orEditText.getText().toString(); + String q3 = notEditText.getText().toString(); + String [] queries = {query,q1,q2,q3}; + showAdvancedResults(queries); + } + }); + + buttonGetTranslations.setOnClickListener(new OnClickListener() { + public void onClick(View v) { + Intent intent; + if (downloadArabicSearchDb) { + downloadArabicSearchDb(); + return; + } else { + intent = new Intent(getApplicationContext(), + TranslationManagerActivity.class); + } + startActivity(intent); + finish(); + } + }); + handleIntent(getIntent()); + } + + + + private void showHideAdvanceSearchLayout(int visiblity) { + LinearLayout advSearchLayout = (LinearLayout) findViewById(R.id.HLAdvSearch); + advSearchLayout.setVisibility(visiblity); + + } + + @Override + public void onPause() { + if (downloadReceiver != null) { + downloadReceiver.setListener(null); + LocalBroadcastManager.getInstance(this) + .unregisterReceiver(downloadReceiver); + downloadReceiver = null; + } + super.onPause(); + } + + private void downloadArabicSearchDb() { + if (downloadReceiver == null) { + downloadReceiver = new DefaultDownloadReceiver(this, + QuranDownloadService.DOWNLOAD_TYPE_ARABIC_SEARCH_DB); + LocalBroadcastManager.getInstance(this).registerReceiver( + downloadReceiver, new IntentFilter( + QuranDownloadNotifier.ProgressIntent.INTENT_NAME)); + } + downloadReceiver.setListener(this); + + String url = QuranFileUtils.getArabicSearchDatabaseUrl(); + String notificationTitle = getString(R.string.search_data); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + QuranFileUtils.getQuranDatabaseDirectory(this), + notificationTitle, SEARCH_INFO_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_ARABIC_SEARCH_DB); + intent.putExtra(QuranDownloadService.EXTRA_OUTPUT_FILE_NAME, + QuranDataProvider.QURAN_ARABIC_DATABASE); + startService(intent); + } + + @Override + public void handleDownloadSuccess() { + warningView.setVisibility(View.GONE); + buttonGetTranslations.setVisibility(View.GONE); + handleIntent(getIntent()); + } + + @Override + public void handleDownloadFailure(int errId) { + } + + @Override + protected void onNewIntent(Intent intent) { + setIntent(intent); + handleIntent(intent); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + String query = args.getString(EXTRA_QUERY); + this.query = query; + if(id==3) + { + String queryAnd = args.getString("AND"); + String queryOr = args.getString("OR"); + String queryNot = args.getString("NOT"); + + return new CursorLoader(this, QuranDataProvider.SEARCH_ADVANCED_URI, + null, null, new String[]{query,queryAnd,queryOr,queryNot}, null); + } + return new CursorLoader(this, QuranDataProvider.SEARCH_URI, + null, null, new String[]{query}, null); + } + + @Override + public void onLoadFinished(Loader loader, Cursor cursor) { + isArabicSearch = QuranUtils.doesStringContainArabic(query); + boolean showArabicWarning = (isArabicSearch && + !QuranFileUtils.hasArabicSearchDatabase(this)); + if (showArabicWarning) { + isArabicSearch = false; + } + + if (cursor == null) { + String active = QuranSettings.getInstance(getApplicationContext()).getActiveTranslation(); + if (TextUtils.isEmpty(active)) { + @StringRes int resource = R.string.no_active_translation; + @StringRes int buttonResource = R.string.translation_settings; + if (showArabicWarning) { + resource = R.string.no_arabic_search_available; + downloadArabicSearchDb = true; + buttonResource = R.string.get_arabic_search_db; + } + messageView.setText(resource); + buttonGetTranslations.setText(getString(buttonResource)); + buttonGetTranslations.setVisibility(View.VISIBLE); + } else { + if (showArabicWarning) { + warningView.setText( + getString(R.string.no_arabic_search_available)); + warningView.setVisibility(View.VISIBLE); + buttonGetTranslations.setText( + getString(R.string.get_arabic_search_db)); + buttonGetTranslations.setVisibility(View.VISIBLE); + } + messageView.setText(getString(R.string.no_results, + new Object[]{ query })); + } + } else { + if (showArabicWarning) { + warningView.setText(R.string.no_arabic_search_available); + warningView.setVisibility(View.VISIBLE); + buttonGetTranslations.setText( + getString(R.string.get_arabic_search_db)); + buttonGetTranslations.setVisibility(View.VISIBLE); + downloadArabicSearchDb = true; + } + + // Display the number of results + int count = cursor.getCount(); + String countString = getResources().getQuantityString( + R.plurals.search_results, count, query, count); + messageView.setText(countString); + + ListView listView = (ListView) findViewById(R.id.results_list); + if (adapter == null) { + adapter = new ResultAdapter(this, cursor); + listView.setAdapter(adapter); + listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, + int position, long id) { + ListView p = (ListView) parent; + final Cursor currentCursor = (Cursor) p.getAdapter().getItem(position); + jumpToResult(currentCursor.getInt(1), currentCursor.getInt(2)); + } + }); + } else { + adapter.changeCursor(cursor); + } + } + } + + @Override + public void onLoaderReset(Loader loader) { + if (adapter != null) { + adapter.changeCursor(null); + } + } + + private void handleIntent(Intent intent) { + if (intent == null) { + return; + } + if (Intent.ACTION_SEARCH.equals(intent.getAction())) { + String query = intent.getStringExtra(SearchManager.QUERY); + showResults(query); + } else if (Intent.ACTION_VIEW.equals(intent.getAction())) { + Uri intentData = intent.getData(); + String query = intent.getStringExtra(SearchManager.USER_QUERY); + if (query == null) { + Bundle extras = intent.getExtras(); + if (extras != null) { + // bug on ics where the above returns null + // http://code.google.com/p/android/issues/detail?id=22978 + Object q = extras.get(SearchManager.USER_QUERY); + if (q != null && q instanceof SpannableString) { + query = q.toString(); + } + } + } + + if (QuranUtils.doesStringContainArabic(query)) { + isArabicSearch = true; + } + + if (isArabicSearch) { + // if we come from muyassar and don't have arabic db, we set + // arabic search to false so we jump to the translation. + if (!QuranFileUtils.hasArabicSearchDatabase(this)) { + isArabicSearch = false; + } + } + + Integer id = null; + try { + id = intentData.getLastPathSegment() != null ? + Integer.valueOf(intentData.getLastPathSegment()) : null; + } catch (NumberFormatException e) { + // no op + } + + if (id != null) { + int sura = 1; + int total = id; + for (int j = 1; j <= 114; j++) { + int cnt = QuranInfo.getNumAyahs(j); + total -= cnt; + if (total >= 0) + sura++; + else { + total += cnt; + break; + } + } + + if (total == 0){ + sura--; + total = QuranInfo.getNumAyahs(sura); + } + + jumpToResult(sura, total); + finish(); + } + } + } + + private void jumpToResult(int sura, int ayah) { + int page = QuranInfo.getPageFromSuraAyah(sura, ayah); + Intent intent = new Intent(this, PagerActivity.class); + intent.putExtra(PagerActivity.EXTRA_HIGHLIGHT_SURA, sura); + intent.putExtra(PagerActivity.EXTRA_HIGHLIGHT_AYAH, ayah); + if (!isArabicSearch) { + intent.putExtra(PagerActivity.EXTRA_JUMP_TO_TRANSLATION, true); + } + intent.putExtra("page", page); + startActivity(intent); + } + + private void showResults(String query) { + Bundle args = new Bundle(); + args.putString(EXTRA_QUERY, query); + getSupportLoaderManager().restartLoader(0, args, this); + } + private void showAdvancedResults(String [] queries) { + Bundle args = new Bundle(); + args.putString(EXTRA_QUERY, queries[0]); + args.putString("AND", queries[1]); + args.putString("OR", queries[2]); + args.putString("NOT", queries[3]); + getSupportLoaderManager().restartLoader(3, args, this); + } + private static class ResultAdapter extends CursorAdapter { + private LayoutInflater mInflater; + private Context mContext; + + ResultAdapter(Context context, Cursor cursor) { + super(context, cursor, 0); + mInflater = LayoutInflater.from(context); + mContext = context; + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + final View view = mInflater.inflate(R.layout.search_result, parent, false); + ViewHolder holder = new ViewHolder(); + holder.text = (TextView) view.findViewById(R.id.verseText); + holder.metadata = (TextView) view.findViewById(R.id.verseLocation); + view.setTag(holder); + return view; + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + final ViewHolder holder = (ViewHolder) view.getTag(); + int sura = cursor.getInt(1); + int ayah = cursor.getInt(2); + String text = cursor.getString(3); + String suraName = QuranInfo.getSuraName(mContext, sura, false); + holder.text.setText(Html.fromHtml(text)); + holder.metadata.setText(mContext.getString(R.string.found_in_sura, suraName, ayah)); + } + + static class ViewHolder { + TextView text; + TextView metadata; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ShortcutsActivity.java b/app/src/main/java/com/quran/labs/androidquran/ShortcutsActivity.java new file mode 100644 index 0000000000..80bcd176f2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ShortcutsActivity.java @@ -0,0 +1,41 @@ +package com.quran.labs.androidquran; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.content.pm.ShortcutManager; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; + +import com.quran.labs.androidquran.ui.QuranActivity; + +public class ShortcutsActivity extends AppCompatActivity { + public static final String ACTION_JUMP_TO_LATEST = "com.quran.labs.androidquran.last_page"; + + private static final String JUMP_TO_LATEST_SHORTCUT_NAME = "lastPage"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Intent shortcutIntent = getIntent(); + final String action = shortcutIntent == null ? null : shortcutIntent.getAction(); + + Intent intent = new Intent(this, QuranActivity.class); + if (ACTION_JUMP_TO_LATEST.equals(action)) { + intent.setAction(action); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + recordShortcutUsage(JUMP_TO_LATEST_SHORTCUT_NAME); + } + } + finish(); + startActivity(intent); + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private void recordShortcutUsage(String shortcut) { + ShortcutManager shortcutManager = getSystemService(ShortcutManager.class); + shortcutManager.reportShortcutUsed(shortcut); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/AyahBounds.java b/app/src/main/java/com/quran/labs/androidquran/common/AyahBounds.java new file mode 100644 index 0000000000..b5ac84d483 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/AyahBounds.java @@ -0,0 +1,31 @@ +package com.quran.labs.androidquran.common; + +import android.graphics.RectF; + +public class AyahBounds { + private int line; + private int position; + private RectF bounds; + + public AyahBounds(int line, int position, int minX, int minY, int maxX, int maxY) { + this.line = line; + this.position = position; + bounds = new RectF(minX, minY, maxX, maxY); + } + + public void engulf(AyahBounds other) { + bounds.union(other.getBounds()); + } + + public RectF getBounds() { + return new RectF(bounds); + } + + public int getLine() { + return line; + } + + public int getPosition() { + return position; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/HighlightInfo.java b/app/src/main/java/com/quran/labs/androidquran/common/HighlightInfo.java new file mode 100644 index 0000000000..784eacad80 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/HighlightInfo.java @@ -0,0 +1,17 @@ +package com.quran.labs.androidquran.common; + +import com.quran.labs.androidquran.ui.helpers.HighlightType; + +public class HighlightInfo { + public final int sura; + public final int ayah; + public final HighlightType highlightType; + public final boolean scrollToAyah; + + public HighlightInfo(int sura, int ayah, HighlightType type, boolean scrollToAyah) { + this.sura = sura; + this.ayah = ayah; + this.highlightType = type; + this.scrollToAyah = scrollToAyah; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/LocalTranslation.java b/app/src/main/java/com/quran/labs/androidquran/common/LocalTranslation.java new file mode 100644 index 0000000000..143e40b08c --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/LocalTranslation.java @@ -0,0 +1,34 @@ +package com.quran.labs.androidquran.common; + +public class LocalTranslation { + public final int id; + public final String filename; + public final String name; + public final String translator; + public final String translatorForeign; + public final String url; + public final int version; + + public LocalTranslation(int id, String filename, String name, + String translator, String translatorForeign, String url, int version) { + this.id = id; + this.filename = filename; + this.name = name; + this.translator = translator; + this.translatorForeign = translatorForeign; + this.url = url; + this.version = version; + } + + public String getTranslatorName() { + final String result; + if (this.translatorForeign != null) { + result = this.translatorForeign; + } else if (this.translator != null) { + result = this.translator; + } else { + result = this.name; + } + return result; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/QariItem.java b/app/src/main/java/com/quran/labs/androidquran/common/QariItem.java new file mode 100644 index 0000000000..4591a5e78f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/QariItem.java @@ -0,0 +1,85 @@ +package com.quran.labs.androidquran.common; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +public class QariItem implements Parcelable { + private final int id; + @NonNull private final String name; + @NonNull private final String url; + @NonNull private final String path; + @Nullable private final String databaseName; + + public QariItem(int id, @NonNull String name, @NonNull String url, + @NonNull String path, @Nullable String databaseName) { + this.id = id; + this.name = name; + this.url = url; + this.path = path; + this.databaseName = TextUtils.isEmpty(databaseName) ? null : databaseName; + } + + private QariItem(Parcel in) { + this.id = in.readInt(); + this.name = in.readString(); + this.url = in.readString(); + this.path = in.readString(); + this.databaseName = in.readString(); + } + + public int getId() { + return id; + } + + public boolean isGapless() { + return databaseName != null; + } + + @NonNull + public String getName() { + return name; + } + + @NonNull + public String getUrl() { + return url; + } + + @NonNull + public String getPath() { + return path; + } + + @Nullable + public String getDatabaseName() { + return databaseName; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(this.id); + dest.writeString(this.name); + dest.writeString(this.url); + dest.writeString(this.path); + dest.writeString(this.databaseName); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public QariItem createFromParcel(Parcel source) { + return new QariItem(source); + } + + public QariItem[] newArray(int size) { + return new QariItem[size]; + } + }; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/QuranAyahInfo.java b/app/src/main/java/com/quran/labs/androidquran/common/QuranAyahInfo.java new file mode 100644 index 0000000000..a599a88ddc --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/QuranAyahInfo.java @@ -0,0 +1,33 @@ +package com.quran.labs.androidquran.common; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.data.QuranInfo; + +import java.util.Collections; +import java.util.List; + +/** + * QuranAyahInfo + * TODO: This should become QuranAyah at some point in the future + * This is because most usages of QuranAyah should actually be usages of SuraAyah + */ +public class QuranAyahInfo { + public final int sura; + public final int ayah; + public final int ayahId; + @Nullable public final String arabicText; + @NonNull public final List texts; + + public QuranAyahInfo(int sura, + int ayah, + @Nullable String arabicText, + @NonNull List texts) { + this.sura = sura; + this.ayah = ayah; + this.arabicText = arabicText; + this.texts = Collections.unmodifiableList(texts); + this.ayahId = QuranInfo.getAyahId(sura, ayah); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/QuranText.java b/app/src/main/java/com/quran/labs/androidquran/common/QuranText.java new file mode 100644 index 0000000000..a5930fe376 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/QuranText.java @@ -0,0 +1,15 @@ +package com.quran.labs.androidquran.common; + +import android.support.annotation.NonNull; + +public class QuranText { + public final int sura; + public final int ayah; + @NonNull public final String text; + + public QuranText(int sura, int ayah, @NonNull String text) { + this.sura = sura; + this.ayah = ayah; + this.text = text; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/common/Response.java b/app/src/main/java/com/quran/labs/androidquran/common/Response.java new file mode 100644 index 0000000000..c37f545111 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/common/Response.java @@ -0,0 +1,48 @@ +package com.quran.labs.androidquran.common; + +import android.graphics.Bitmap; + +public class Response { + public static final int ERROR_SD_CARD_NOT_FOUND = 1; + public static final int ERROR_FILE_NOT_FOUND = 2; + public static final int ERROR_DOWNLOADING_ERROR = 3; + public static final int WARN_SD_CARD_NOT_FOUND = 4; + public static final int WARN_COULD_NOT_SAVE_FILE = 5; + + private Bitmap bitmap; + private int errorCode; + private int pageNumber; + + public Response(Bitmap bitmap) { + this.bitmap = bitmap; + } + + public Response(Bitmap bitmap, int warningCode) { + this.bitmap = bitmap; + // we currently ignore warnings + } + + public Response(int errorCode) { + this.errorCode = errorCode; + } + + public int getPageNumber() { + return pageNumber; + } + + public void setPageData(int pageNumber) { + this.pageNumber = pageNumber; + } + + public Bitmap getBitmap() { + return bitmap; + } + + public boolean isSuccessful() { + return errorCode == 0; + } + + public int getErrorCode() { + return errorCode; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/component/activity/PagerActivityComponent.java b/app/src/main/java/com/quran/labs/androidquran/component/activity/PagerActivityComponent.java new file mode 100644 index 0000000000..863f90439e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/component/activity/PagerActivityComponent.java @@ -0,0 +1,24 @@ +package com.quran.labs.androidquran.component.activity; + +import com.quran.labs.androidquran.component.fragment.QuranPageComponent; +import com.quran.labs.androidquran.di.ActivityScope; +import com.quran.labs.androidquran.module.activity.PagerActivityModule; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.fragment.AyahTranslationFragment; + +import dagger.Subcomponent; + +@ActivityScope +@Subcomponent(modules = PagerActivityModule.class) +public interface PagerActivityComponent { + // subcomponents + QuranPageComponent.Builder quranPageComponentBuilder(); + + void inject(PagerActivity pagerActivity); + void inject(AyahTranslationFragment ayahTranslationFragment); + + @Subcomponent.Builder interface Builder { + Builder withPagerActivityModule(PagerActivityModule pagerModule); + PagerActivityComponent build(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/component/application/ApplicationComponent.java b/app/src/main/java/com/quran/labs/androidquran/component/application/ApplicationComponent.java new file mode 100644 index 0000000000..44c6d439dc --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/component/application/ApplicationComponent.java @@ -0,0 +1,47 @@ +package com.quran.labs.androidquran.component.application; + +import com.quran.labs.androidquran.QuranImportActivity; +import com.quran.labs.androidquran.component.activity.PagerActivityComponent; +import com.quran.labs.androidquran.data.QuranDataProvider; +import com.quran.labs.androidquran.module.application.ApplicationModule; +import com.quran.labs.androidquran.module.application.DatabaseModule; +import com.quran.labs.androidquran.module.application.NetworkModule; +import com.quran.labs.androidquran.service.QuranDownloadService; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.ui.TranslationManagerActivity; +import com.quran.labs.androidquran.ui.fragment.AddTagDialog; +import com.quran.labs.androidquran.ui.fragment.BookmarksFragment; +import com.quran.labs.androidquran.ui.fragment.QuranAdvancedSettingsFragment; +import com.quran.labs.androidquran.ui.fragment.QuranSettingsFragment; +import com.quran.labs.androidquran.ui.fragment.TagBookmarkDialog; + +import javax.inject.Singleton; + +import dagger.Component; + +@Singleton +@Component(modules = { ApplicationModule.class, DatabaseModule.class, NetworkModule.class } ) +public interface ApplicationComponent { + // subcomponents + PagerActivityComponent.Builder pagerActivityComponentBuilder(); + + // content provider + void inject(QuranDataProvider quranDataProvider); + + // services + void inject(QuranDownloadService quranDownloadService); + + // activities + void inject(QuranActivity quranActivity); + void inject(QuranImportActivity quranImportActivity); + + // fragments + void inject(BookmarksFragment bookmarksFragment); + void inject(QuranSettingsFragment fragment); + void inject(TranslationManagerActivity translationManagerActivity); + void inject(QuranAdvancedSettingsFragment quranAdvancedSettingsFragment); + + // dialogs + void inject(TagBookmarkDialog tagBookmarkDialog); + void inject(AddTagDialog addTagDialog); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/component/fragment/QuranPageComponent.java b/app/src/main/java/com/quran/labs/androidquran/component/fragment/QuranPageComponent.java new file mode 100644 index 0000000000..2bc42867c7 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/component/fragment/QuranPageComponent.java @@ -0,0 +1,22 @@ +package com.quran.labs.androidquran.component.fragment; + +import com.quran.labs.androidquran.di.QuranPageScope; +import com.quran.labs.androidquran.module.fragment.QuranPageModule; +import com.quran.labs.androidquran.ui.fragment.QuranPageFragment; +import com.quran.labs.androidquran.ui.fragment.TabletFragment; +import com.quran.labs.androidquran.ui.fragment.TranslationFragment; + +import dagger.Subcomponent; + +@QuranPageScope +@Subcomponent(modules = QuranPageModule.class) +public interface QuranPageComponent { + void inject(QuranPageFragment quranPageFragment); + void inject(TabletFragment tabletFragment); + void inject(TranslationFragment translationFragment); + + @Subcomponent.Builder interface Builder { + Builder withQuranPageModule(QuranPageModule quranPageModule); + QuranPageComponent build(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/Bookmark.java b/app/src/main/java/com/quran/labs/androidquran/dao/Bookmark.java new file mode 100644 index 0000000000..22bde17397 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/Bookmark.java @@ -0,0 +1,48 @@ +package com.quran.labs.androidquran.dao; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Bookmark { + + public final long id; + public final Integer sura; + public final Integer ayah; + public final int page; + public final long timestamp; + public final List tags; + + public Bookmark(long id, Integer sura, Integer ayah, int page) { + this(id, sura, ayah, page, System.currentTimeMillis()); + } + + public Bookmark(long id, Integer sura, Integer ayah, int page, long timestamp) { + this(id, sura, ayah, page, timestamp, Collections.emptyList()); + } + + public Bookmark(long id, Integer sura, Integer ayah, int page, long timestamp, List tags) { + this.id = id; + this.sura = sura; + this.ayah = ayah; + this.page = page; + this.timestamp = timestamp; + this.tags = Collections.unmodifiableList(tags); + } + + public boolean isPageBookmark() { + return sura == null && ayah == null; + } + + public Bookmark withTags(List tagIds) { + return new Bookmark(id, sura, ayah, page, timestamp, new ArrayList<>(tagIds)); + } + + public String getAyahText() { + return null; + } + + public Bookmark withAyahText(String ayahText) { + return new BookmarkWithAyahText(this, ayahText); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/BookmarkData.java b/app/src/main/java/com/quran/labs/androidquran/dao/BookmarkData.java new file mode 100644 index 0000000000..b7f74ad713 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/BookmarkData.java @@ -0,0 +1,28 @@ +package com.quran.labs.androidquran.dao; + +import java.util.ArrayList; +import java.util.List; + +public class BookmarkData { + private final List tags; + private final List bookmarks; + private final List recentPages; + + public BookmarkData(List tags, List bookmarks, List recentPages) { + this.tags = tags == null ? new ArrayList<>() : tags; + this.bookmarks = bookmarks == null ? new ArrayList<>() : bookmarks; + this.recentPages = recentPages == null ? new ArrayList<>() : recentPages; + } + + public List getTags() { + return this.tags; + } + + public List getBookmarks() { + return this.bookmarks; + } + + public List getRecentPages() { + return this.recentPages; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/BookmarkWithAyahText.java b/app/src/main/java/com/quran/labs/androidquran/dao/BookmarkWithAyahText.java new file mode 100644 index 0000000000..7cda2685c7 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/BookmarkWithAyahText.java @@ -0,0 +1,16 @@ +package com.quran.labs.androidquran.dao; + +public class BookmarkWithAyahText extends Bookmark { + public final String ayahText; + + public BookmarkWithAyahText(Bookmark bookmark, String ayahText) { + super(bookmark.id, bookmark.sura, bookmark.ayah, + bookmark.page, bookmark.timestamp, bookmark.tags); + this.ayahText = ayahText; + } + + @Override + public String getAyahText() { + return this.ayahText; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/RecentPage.java b/app/src/main/java/com/quran/labs/androidquran/dao/RecentPage.java new file mode 100644 index 0000000000..1fe178f5e2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/RecentPage.java @@ -0,0 +1,11 @@ +package com.quran.labs.androidquran.dao; + +public class RecentPage { + public final int page; + public final long timestamp; + + public RecentPage(int page, long timestamp) { + this.page = page; + this.timestamp = timestamp; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/Tag.java b/app/src/main/java/com/quran/labs/androidquran/dao/Tag.java new file mode 100644 index 0000000000..b8b527cbe1 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/Tag.java @@ -0,0 +1,43 @@ +package com.quran.labs.androidquran.dao; + +import android.os.Parcel; + +public class Tag { + + public final long id; + public final String name; + + public Tag(long id, String name) { + this.id = id; + this.name = name; + } + + public Tag(Parcel parcel) { + id = parcel.readLong(); + name = parcel.readString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + + Tag tag = (Tag) o; + return id == tag.id && name.equals(tag.name); + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + name.hashCode(); + return result; + } + + @Override + public String toString() { + return name == null ? super.toString() : name; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/translation/Translation.java b/app/src/main/java/com/quran/labs/androidquran/dao/translation/Translation.java new file mode 100644 index 0000000000..59e90e910d --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/translation/Translation.java @@ -0,0 +1,31 @@ +package com.quran.labs.androidquran.dao.translation; + +import com.squareup.moshi.Json; + +public class Translation { + public final int id; + public final String displayName; + public final String downloadType; + public final String fileUrl; + public final String saveTo; + public final String translator; + @Json(name = "minimum_version") public final int minimumVersion; + @Json(name = "current_version") public final int currentVersion; + @Json(name = "fileName") public final String filename; + @Json(name = "translator_foreign") public final String translatorNameLocalized; + + public Translation(int id, int minimumVersion, int currentVersion, String displayName, + String downloadType, String filename, String fileUrl, String saveTo, + String translator, String translatorNameLocalized) { + this.id = id; + this.minimumVersion = minimumVersion; + this.currentVersion = currentVersion; + this.displayName = displayName; + this.downloadType = downloadType; + this.filename = filename; + this.fileUrl = fileUrl; + this.saveTo = saveTo; + this.translator = translator; + this.translatorNameLocalized = translatorNameLocalized; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationHeader.java b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationHeader.java new file mode 100644 index 0000000000..6dce48c397 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationHeader.java @@ -0,0 +1,24 @@ +package com.quran.labs.androidquran.dao.translation; + +public class TranslationHeader implements TranslationRowData { + public final String name; + + public TranslationHeader(String name) { + this.name = name; + } + + @Override + public String name() { + return this.name; + } + + @Override + public boolean isSeparator() { + return true; + } + + @Override + public boolean needsUpgrade() { + return false; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationItem.java b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationItem.java new file mode 100644 index 0000000000..619a1489ca --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationItem.java @@ -0,0 +1,42 @@ +package com.quran.labs.androidquran.dao.translation; + +public class TranslationItem implements TranslationRowData { + public final int localVersion; + public final Translation translation; + + public TranslationItem(Translation translation) { + this(translation, 0); + } + + public TranslationItem(Translation translation, int localVersion) { + this.translation = translation; + this.localVersion = localVersion; + } + + public boolean exists() { + return localVersion > 0; + } + + @Override + public String name() { + return this.translation.displayName; + } + + @Override + public boolean isSeparator() { + return false; + } + + @Override + public boolean needsUpgrade() { + return localVersion > 0 && this.translation.currentVersion > this.localVersion; + } + + public TranslationItem withTranslationRemoved() { + return new TranslationItem(this.translation, 0); + } + + public TranslationItem withTranslationVersion(int version) { + return new TranslationItem(this.translation, version); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationList.java b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationList.java new file mode 100644 index 0000000000..54d94e89f8 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationList.java @@ -0,0 +1,13 @@ +package com.quran.labs.androidquran.dao.translation; + +import com.squareup.moshi.Json; + +import java.util.List; + +public class TranslationList { + @Json(name = "data") public final List translations; + + public TranslationList(List translations) { + this.translations = translations; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationRowData.java b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationRowData.java new file mode 100644 index 0000000000..4575ee5cfc --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/dao/translation/TranslationRowData.java @@ -0,0 +1,7 @@ +package com.quran.labs.androidquran.dao.translation; + +public interface TranslationRowData { + String name(); + boolean isSeparator(); + boolean needsUpgrade(); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java b/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java new file mode 100644 index 0000000000..00a5f9a19f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java @@ -0,0 +1,131 @@ +package com.quran.labs.androidquran.data; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.graphics.RectF; +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.database.DatabaseUtils; +import com.quran.labs.androidquran.util.QuranFileUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AyahInfoDatabaseHandler { + + private static final String COL_PAGE = "page_number"; + private static final String COL_LINE = "line_number"; + private static final String COL_SURA = "sura_number"; + private static final String COL_AYAH = "ayah_number"; + private static final String COL_POSITION = "position"; + private static final String MIN_X = "min_x"; + private static final String MIN_Y = "min_y"; + private static final String MAX_X = "max_x"; + private static final String MAX_Y = "max_y"; + private static final String GLYPHS_TABLE = "glyphs"; + + private static Map ayahInfoCache = new HashMap<>(); + + private final SQLiteDatabase database; + + static AyahInfoDatabaseHandler getAyahInfoDatabaseHandler(Context context, String databaseName) { + AyahInfoDatabaseHandler handler = ayahInfoCache.get(databaseName); + if (handler == null) { + try { + AyahInfoDatabaseHandler db = new AyahInfoDatabaseHandler(context, databaseName); + if (db.validDatabase()) { + ayahInfoCache.put(databaseName, db); + handler = db; + } + } catch (SQLException sqlException) { + // it's okay, we'll try again later + } + } + return handler; + } + + private AyahInfoDatabaseHandler(Context context, String databaseName) throws SQLException { + String base = QuranFileUtils.getQuranAyahDatabaseDirectory(context); + if (base == null) { + database = null; + } else { + String path = base + File.separator + databaseName; + database = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.NO_LOCALIZED_COLLATORS); + } + } + + private boolean validDatabase() { + return database != null && database.isOpen(); + } + + @NonNull + public RectF getPageBounds(int page) { + Cursor c = null; + try { + String[] colNames = new String[]{ + "MIN(" + MIN_X + ")", "MIN(" + MIN_Y + ")", + "MAX(" + MAX_X + ")", "MAX(" + MAX_Y + ")" }; + c = database.query(GLYPHS_TABLE, colNames, COL_PAGE + "=" + page, null, null, null, null); + if (c.moveToFirst()) { + return new RectF(c.getInt(0), c.getInt(1), c.getInt(2), c.getInt(3)); + } else { + throw new IllegalArgumentException("getPageBounds() on a non-existent page: " + page); + } + } finally { + DatabaseUtils.closeCursor(c); + } + } + + @NonNull + public Map> getVersesBoundsForPage(int page) { + Map> result = new HashMap<>(); + Cursor cursor = null; + try { + cursor = getVersesBoundsCursorForPage(page); + while (cursor.moveToNext()) { + int sura = cursor.getInt(2); + int ayah = cursor.getInt(3); + String key = sura + ":" + ayah; + List bounds = result.get(key); + if (bounds == null) { + bounds = new ArrayList<>(); + } + + AyahBounds last = null; + if (bounds.size() > 0) { + last = bounds.get(bounds.size() - 1); + } + + AyahBounds bound = new AyahBounds(cursor.getInt(1), + cursor.getInt(4), cursor.getInt(5), + cursor.getInt(6), cursor.getInt(7), + cursor.getInt(8)); + if (last != null && last.getLine() == bound.getLine()) { + last.engulf(bound); + } else { + bounds.add(bound); + } + result.put(key, bounds); + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + + return result; + } + + private Cursor getVersesBoundsCursorForPage(int page) { + return database.query(GLYPHS_TABLE, + new String[]{ COL_PAGE, COL_LINE, COL_SURA, COL_AYAH, + COL_POSITION, MIN_X, MIN_Y, MAX_X, MAX_Y }, + COL_PAGE + "=" + page, + null, null, null, + COL_SURA + "," + COL_AYAH + "," + COL_POSITION); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseProvider.java b/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseProvider.java new file mode 100644 index 0000000000..2f107d0621 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseProvider.java @@ -0,0 +1,31 @@ +package com.quran.labs.androidquran.data; + +import android.content.Context; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.di.ActivityScope; +import com.quran.labs.androidquran.util.QuranFileUtils; + +import javax.inject.Inject; + +@ActivityScope +public class AyahInfoDatabaseProvider { + private final Context context; + private final String widthParameter; + @Nullable private AyahInfoDatabaseHandler databaseHandler; + + @Inject + AyahInfoDatabaseProvider(Context context, String widthParameter) { + this.context = context; + this.widthParameter = widthParameter; + } + + @Nullable + public AyahInfoDatabaseHandler getAyahInfoHandler() { + if (databaseHandler == null) { + String filename = QuranFileUtils.getAyaPositionFileName(widthParameter); + databaseHandler = AyahInfoDatabaseHandler.getAyahInfoDatabaseHandler(context, filename); + } + return databaseHandler; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranData.java b/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranData.java new file mode 100644 index 0000000000..5f08f745f4 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranData.java @@ -0,0 +1,352 @@ +package com.quran.labs.androidquran.data; + +public class BaseQuranData { + public static int[] SURA_PAGE_START = { + 1, 2, 50, 77, 106, 128, 151, 177, 187, 208, 221, 235, 249, 255, 262, + 267, 282, 293, 305, 312, 322, 332, 342, 350, 359, 367, 377, 385, 396, + 404, 411, 415, 418, 428, 434, 440, 446, 453, 458, 467, 477, 483, 489, + 496, 499, 502, 507, 511, 515, 518, 520, 523, 526, 528, 531, 534, 537, + 542, 545, 549, 551, 553, 554, 556, 558, 560, 562, 564, 566, 568, 570, + 572, 574, 575, 577, 578, 580, 582, 583, 585, 586, 587, 587, 589, 590, + 591, 591, 592, 593, 594, 595, 595, 596, 596, 597, 597, 598, 598, 599, + 599, 600, 600, 601, 601, 601, 602, 602, 602, 603, 603, 603, 604, 604, + 604 + }; + + public static int[] PAGE_SURA_START = { + 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 13, 13, + 13, 13, 13, 13, 13, 14, 14, 14, 14, 14, 14, 15, 15, 15, 15, 15, 15, 16, + 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 17, 17, 17, 17, 17, + 17, 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, + 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21, + 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, 22, 22, 22, + 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, + 27, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 31, 31, + 31, 31, 32, 32, 32, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 34, 34, 34, + 34, 34, 34, 34, 35, 35, 35, 35, 35, 35, 36, 36, 36, 36, 36, 37, 37, 37, + 37, 37, 37, 37, 38, 38, 38, 38, 38, 38, 39, 39, 39, 39, 39, 39, 39, 39, + 39, 40, 40, 40, 40, 40, 40, 40, 40, 40, 41, 41, 41, 41, 41, 41, 42, 42, + 42, 42, 42, 42, 42, 43, 43, 43, 43, 43, 43, 44, 44, 44, 45, 45, 45, 45, + 46, 46, 46, 46, 47, 47, 47, 47, 48, 48, 48, 48, 48, 49, 49, 50, 50, 50, + 51, 51, 51, 52, 52, 53, 53, 53, 54, 54, 54, 55, 55, 55, 56, 56, 56, 57, + 57, 57, 57, 58, 58, 58, 58, 59, 59, 59, 60, 60, 60, 61, 62, 62, 63, 64, + 64, 65, 65, 66, 66, 67, 67, 67, 68, 68, 69, 69, 70, 70, 71, 72, 72, 73, + 73, 74, 74, 75, 76, 76, 77, 78, 78, 79, 80, 81, 82, 83, 83, 85, 86, 87, + 89, 89, 91, 92, 95, 97, 98, 100, 103, 106, 109, 112 + }; + + public static int[] PAGE_AYAH_START = { + 1, 1, 6, 17, 25, 30, 38, 49, 58, 62, 70, 77, 84, 89, 94, 102, 106, 113, + 120, 127, 135, 142, 146, 154, 164, 170, 177, 182, 187, 191, 197, 203, + 211, 216, 220, 225, 231, 234, 238, 246, 249, 253, 257, 260, 265, 270, + 275, 282, 283, 1, 10, 16, 23, 30, 38, 46, 53, 62, 71, 78, 84, 92, 101, + 109, 116, 122, 133, 141, 149, 154, 158, 166, 174, 181, 187, 195, 1, 7, + 12, 15, 20, 24, 27, 34, 38, 45, 52, 60, 66, 75, 80, 87, 92, 95, 102, + 106, 114, 122, 128, 135, 141, 148, 155, 163, 171, 176, 3, 6, 10, 14, + 18, 24, 32, 37, 42, 46, 51, 58, 65, 71, 77, 83, 90, 96, 104, 109, 114, + 1, 9, 19, 28, 36, 45, 53, 60, 69, 74, 82, 91, 95, 102, 111, 119, 125, + 132, 138, 143, 147, 152, 158, 1, 12, 23, 31, 38, 44, 52, 58, 68, 74, + 82, 88, 96, 105, 121, 131, 138, 144, 150, 156, 160, 164, 171, 179, 188, + 196, 1, 9, 17, 26, 34, 41, 46, 53, 62, 70, 1, 7, 14, 21, 27, 32, 37, + 41, 48, 55, 62, 69, 73, 80, 87, 94, 100, 107, 112, 118, 123, 1, 7, 15, + 21, 26, 34, 43, 54, 62, 71, 79, 89, 98, 107, 6, 13, 20, 29, 38, 46, 54, + 63, 72, 82, 89, 98, 109, 118, 5, 15, 23, 31, 38, 44, 53, 64, 70, 79, + 87, 96, 104, 1, 6, 14, 19, 29, 35, 43, 6, 11, 19, 25, 34, 43, 1, 16, + 32, 52, 71, 91, 7, 15, 27, 35, 43, 55, 65, 73, 80, 88, 94, 103, 111, + 119, 1, 8, 18, 28, 39, 50, 59, 67, 76, 87, 97, 105, 5, 16, 21, 28, 35, + 46, 54, 62, 75, 84, 98, 1, 12, 26, 39, 52, 65, 77, 96, 13, 38, 52, 65, + 77, 88, 99, 114, 126, 1, 11, 25, 36, 45, 58, 73, 82, 91, 102, 1, 6, + 16, 24, 31, 39, 47, 56, 65, 73, 1, 18, 28, 43, 60, 75, 90, 105, 1, + 11, 21, 28, 32, 37, 44, 54, 59, 62, 3, 12, 21, 33, 44, 56, 68, 1, 20, + 40, 61, 84, 112, 137, 160, 184, 207, 1, 14, 23, 36, 45, 56, 64, 77, + 89, 6, 14, 22, 29, 36, 44, 51, 60, 71, 78, 85, 7, 15, 24, 31, 39, 46, + 53, 64, 6, 16, 25, 33, 42, 51, 1, 12, 20, 29, 1, 12, 21, 1, 7, 16, 23, + 31, 36, 44, 51, 55, 63, 1, 8, 15, 23, 32, 40, 49, 4, 12, 19, 31, 39, + 45, 13, 28, 41, 55, 71, 1, 25, 52, 77, 103, 127, 154, 1, 17, 27, 43, + 62, 84, 6, 11, 22, 32, 41, 48, 57, 68, 75, 8, 17, 26, 34, 41, 50, 59, + 67, 78, 1, 12, 21, 30, 39, 47, 1, 11, 16, 23, 32, 45, 52, 11, 23, 34, + 48, 61, 74, 1, 19, 40, 1, 14, 23, 33, 6, 15, 21, 29, 1, 12, 20, 30, 1, + 10, 16, 24, 29, 5, 12, 1, 16, 36, 7, 31, 52, 15, 32, 1, 27, 45, 7, 28, + 50, 17, 41, 68, 17, 51, 77, 4, 12, 19, 25, 1, 7, 12, 22, 4, 10, 17, 1, + 6, 12, 6, 1, 9, 5, 1, 10, 1, 6, 1, 8, 1, 13, 27, 16, 43, 9, 35, 11, 40, + 11, 1, 14, 1, 20, 18, 48, 20, 6, 26, 20, 1, 31, 16, 1, 1, 1, 7, 35, 1, + 1, 16, 1, 24, 1, 15, 1, 1, 8, 10, 1, 1, 1, 1 + }; + + public static int[] JUZ_PAGE_START = { + 1, 22, 42, 62, 82, 102, 121, 142, 162, 182, + 201, 222, 242, 262, 282, 302, 322, 342, 362, 382, + 402, 422, 442, 462, 482, 502, 522, 542, 562, 582 + }; + + public static int[] PAGE_RUB3_START = { + -1, -1, -1, -1, 1, -1, 2, -1, 3, -1, 4, -1, -1, 5, -1, -1, 6, -1, 7, + -1, -1, 8, -1, 9, -1, -1, 10, -1, 11, -1, -1, 12, -1, 13, -1, -1, 14, + -1, 15, -1, -1, 16, -1, 17, -1, 18, -1, -1, 19, -1, 20, -1, -1, 21, -1, + 22, -1, -1, 23, -1, -1, 24, -1, 25, -1, -1, 26, -1, 27, -1, -1, 28, -1, + 29, -1, -1, 30, -1, 31, -1, -1, 32, -1, 33, -1, -1, 34, -1, 35, -1, -1, + 36, -1, 37, -1, -1, 38, -1, -1, 39, -1, 40, -1, 41, -1, 42, -1, -1, 43, + -1, -1, 44, -1, 45, -1, -1, 46, -1, 47, -1, 48, -1, -1, 49, -1, 50, -1, + -1, 51, -1, -1, 52, -1, 53, -1, -1, 54, -1, -1, 55, -1, 56, -1, 57, -1, + 58, -1, 59, -1, -1, 60, -1, -1, 61, -1, 62, -1, 63, -1, -1, -1, 64, -1, + 65, -1, -1, 66, -1, -1, 67, -1, -1, 68, -1, 69, -1, 70, -1, 71, -1, -1, + 72, -1, 73, -1, -1, 74, -1, 75, -1, -1, 76, -1, 77, -1, 78, -1, -1, 79, + -1, 80, -1, -1, 81, -1, 82, -1, -1, 83, -1, -1, 84, -1, 85, -1, -1, 86, + -1, 87, -1, -1, 88, -1, 89, -1, 90, -1, 91, -1, -1, 92, -1, 93, -1, -1, + 94, -1, 95, -1, -1, -1, 96, -1, 97, -1, -1, 98, -1, 99, -1, -1, 100, -1, + 101, -1, 102, -1, -1, 103, -1, -1, 104, -1, 105, -1, -1, 106, -1, -1, + 107, -1, 108, -1, -1, 109, -1, 110, -1, -1, 111, -1, 112, -1, 113, -1, + -1, 114, -1, 115, -1, -1, 116, -1, -1, 117, -1, 118, -1, 119, -1, -1, + 120, -1, 121, -1, 122, -1, -1, 123, -1, -1, 124, -1, -1, 125, -1, 126, + -1, 127, -1, -1, 128, -1, 129, -1, 130, -1, -1, 131, -1, -1, 132, -1, + 133, -1, 134, -1, -1, 135, -1, -1, 136, -1, 137, -1, -1, 138, -1, -1, + 139, -1, 140, -1, 141, -1, 142, -1, -1, 143, -1, -1, 144, -1, 145, -1, + -1, 146, -1, 147, -1, 148, -1, -1, 149, -1, -1, 150, -1, 151, -1, -1, + 152, -1, 153, -1, 154, -1, -1, 155, -1, -1, 156, -1, 157, -1, 158, -1, + -1, 159, -1, -1, 160, -1, 161, -1, -1, 162, -1, -1, 163, -1, -1, 164, + -1, 165, -1, -1, 166, -1, 167, -1, 168, -1, -1, 169, 170, -1, -1, 171, + -1, 172, -1, 173, -1, -1, 174, -1, -1, 175, -1, -1, 176, -1, 177, -1, + 178, -1, -1, 179, -1, 180, -1, -1, 181, -1, 182, -1, -1, 183, -1, -1, + 184, -1, 185, -1, -1, 186, -1, 187, -1, -1, 188, -1, 189, -1, -1, 190, + -1, 191, -1, -1, 192, -1, 193, -1, 194, -1, 195, -1, -1, 196, -1, 197, + -1, -1, 198, -1, -1, 199, -1, -1, 200, -1, -1, 201, -1, 202, -1, -1, + 203, -1, -1, 204, -1, 205, -1, 206, -1, 207, -1, -1, 208, -1, 209, -1, + 210, -1, -1, 211, -1, 212, -1, -1, 213, -1, 214, -1, -1, 215, -1, -1, + 216, -1, 217, -1, -1, 218, -1, -1, 219, -1, -1, 220, 221, -1, -1, -1, + 222, -1, 223, -1, 224, -1, 225, -1, 226, -1, -1, 227, -1, -1, 228, -1, + -1, 229, -1, 230, -1, 231, -1, -1, 232, -1, -1, 233, -1, 234, -1, 235, + -1, 236, -1, -1, 237, -1, 238, -1, -1, 239, -1, -1, -1, -1, -1 + }; + + public static int[] SURA_NUM_AYAHS = { + 7, 286, 200, 176, 120, 165, 206, 75, 129, 109, 123, 111, + 43, 52, 99, 128, 111, 110, 98, 135, 112, 78, 118, 64, 77, + 227, 93, 88, 69, 60, 34, 30, 73, 54, 45, 83, 182, 88, 75, + 85, 54, 53, 89, 59, 37, 35, 38, 29, 18, 45, 60, 49, 62, 55, + 78, 96, 29, 22, 24, 13, 14, 11, 11, 18, 12, 12, 30, 52, 52, + 44, 28, 28, 20, 56, 40, 31, 50, 40, 46, 42, 29, 19, 36, 25, + 22, 17, 19, 26, 30, 20, 15, 21, 11, 8, 8, 19, 5, 8, 8, 11, + 11, 8, 3, 9, 5, 4, 7, 3, 6, 3, 5, 4, 5, 6 + }; + + public static boolean[] SURA_IS_MAKKI = { + // 1 - 10 + true, false, false, false, false, true, true, false, false, true, + // 11 - 20 + true, true, false, true, true, true, true, true, true, true, + // 21 - 30 + true, false, true, false, true, true, true, true, true, true, + // 31 - 40 + true, true, false, true, true, true, true, true, true, true, + // 41 - 50 + true, true, true, true, true, true, false, false, false, true, + // 51 - 60 + true, true, true, true, false, true, false, false, false, false, + // 61 - 70 + false, false, false, false, false, false, true, true, true, true, + // 71 - 80 + true, true, true, true, true, false, true, true, true, true, + // 81 - 90 + true, true, true, true, true, true, true, true, true, true, + // 91 - 100 + true, true, true, true, true, true, true, false, false, true, + // 101 - 110 + true, true, true, true, true, true, true, true, true, false, + // 111 - 114 + true, true, true, true + }; + + public static int[][] QUARTERS = new int[][]{ + // hizb 1 + {1, 1}, {2, 26}, {2, 44}, {2, 60}, + + // hizb 2 + {2, 75}, {2, 92}, {2, 106}, {2, 124}, + + // hizb 3 + {2, 142}, {2, 158}, {2, 177}, {2, 189}, + + // hizb 4 + {2, 203}, {2, 219}, {2, 233}, {2, 243}, + + // hizb 5 + {2, 253}, {2, 263}, {2, 272}, {2, 283}, + + // hizb 6 + {3, 15}, {3, 33}, {3, 52}, {3, 75}, + + // hizb 7 + {3, 93}, {3, 113}, {3, 133}, {3, 153}, + + // hizb 8 + {3, 171}, {3, 186}, {4, 1}, {4, 12}, + + // hizb 9 + {4, 24}, {4, 36}, {4, 58}, {4, 74}, + + // hizb 10 + {4, 88}, {4, 100}, {4, 114}, {4, 135}, + + // hizb 11 + {4, 148}, {4, 163}, {5, 1}, {5, 12}, + + // hizb 12 + {5, 27}, {5, 41}, {5, 51}, {5, 67}, + + // hizb 13 + {5, 82}, {5, 97}, {5, 109}, {6, 13}, + + // hizb 14 + {6, 36}, {6, 59}, {6, 74}, {6, 95}, + + // hizb 15 + {6, 111}, {6, 127}, {6, 141}, {6, 151}, + + // hizb 16 + {7, 1}, {7, 31}, {7, 47}, {7, 65}, + + // hizb 17 + {7, 88}, {7, 117}, {7, 142}, {7, 156}, + + // hizb 18 + {7, 171}, {7, 189}, {8, 1}, {8, 22}, + + // hizb 19 + {8, 41}, {8, 61}, {9, 1}, {9, 19}, + + // hizb 20 + {9, 34}, {9, 46}, {9, 60}, {9, 75}, + + // hizb 21 + {9, 93}, {9, 111}, {9, 122}, {10, 11}, + + // hizb 22 + {10, 26}, {10, 53}, {10, 71}, {10, 90}, + + // hizb 23 + {11, 6}, {11, 24}, {11, 41}, {11, 61}, + + // hizb 24 + {11, 84}, {11, 108}, {12, 7}, {12, 30}, + + // hizb 25 + {12, 53}, {12, 77}, {12, 101}, {13, 5}, + + // hizb 26 + {13, 19}, {13, 35}, {14, 10}, {14, 28}, + + // hizb 27 + {15, 1}, {15, 49}, {16, 1}, {16, 30}, + + // hizb 28 + {16, 51}, {16, 75}, {16, 90}, {16, 111}, + + // hizb 29 + {17, 1}, {17, 23}, {17, 50}, {17, 70}, + + // hizb 30 + {17, 99}, {18, 17}, {18, 32}, {18, 51}, + + // hizb 31 + {18, 75}, {18, 99}, {19, 22}, {19, 59}, + + // hizb 32 + {20, 1}, {20, 55}, {20, 83}, {20, 111}, + + // hizb 33 + {21, 1}, {21, 29}, {21, 51}, {21, 83}, + + // hizb 34 + {22, 1}, {22, 19}, {22, 38}, {22, 60}, + + // hizb 35 + {23, 1}, {23, 36}, {23, 75}, {24, 1}, + + // hizb 36 + {24, 21}, {24, 35}, {24, 53}, {25, 1}, + + // hizb 37 + {25, 21}, {25, 53}, {26, 1}, {26, 52}, + + // hizb 38 + {26, 111}, {26, 181}, {27, 1}, {27, 27}, + + // hizb 39 + {27, 56}, {27, 82}, {28, 12}, {28, 29}, + + // hizb 40 + {28, 51}, {28, 76}, {29, 1}, {29, 26}, + + // hizb 41 + {29, 46}, {30, 1}, {30, 31}, {30, 54}, + + // hizb 42 + {31, 22}, {32, 11}, {33, 1}, {33, 18}, + + // hizb 43 + {33, 31}, {33, 51}, {33, 60}, {34, 10}, + + // hizb 44 + {34, 24}, {34, 46}, {35, 15}, {35, 41}, + + // hizb 45 + {36, 28}, {36, 60}, {37, 22}, {37, 83}, + + // hizb 46 + {37, 145}, {38, 21}, {38, 52}, {39, 8}, + + // hizb 47 + {39, 32}, {39, 53}, {40, 1}, {40, 21}, + + // hizb 48 + {40, 41}, {40, 66}, {41, 9}, {41, 25}, + + // hizb 49 + {41, 47}, {42, 13}, {42, 27}, {42, 51}, + + // hizb 50 + {43, 24}, {43, 57}, {44, 17}, {45, 12}, + + // hizb 51 + {46, 1}, {46, 21}, {47, 10}, {47, 33}, + + // hizb 52 + {48, 18}, {49, 1}, {49, 14}, {50, 27}, + + // hizb 53 + {51, 31}, {52, 24}, {53, 26}, {54, 9}, + + // hizb 54 + {55, 1}, {56, 1}, {56, 75}, {57, 16}, + + // hizb 55 + {58, 1}, {58, 14}, {59, 11}, {60, 7}, + + // hizb 56 + {62, 1}, {63, 4}, {65, 1}, {66, 1}, + + // hizb 57 + {67, 1}, {68, 1}, {69, 1}, {70, 19}, + + // hizb 58 + {72, 1}, {73, 20}, {75, 1}, {76, 19}, + + // hizb 59 + {78, 1}, {80, 1}, {82, 1}, {84, 1}, + + // hizb 60 + {87, 1}, {90, 1}, {94, 1}, {100, 9}, + }; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranInfo.java b/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranInfo.java new file mode 100644 index 0000000000..d7b08bb50d --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranInfo.java @@ -0,0 +1,327 @@ +package com.quran.labs.androidquran.data; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.util.QuranUtils; + +import java.util.LinkedHashSet; +import java.util.Set; + +import static com.quran.labs.androidquran.data.Constants.PAGES_FIRST; +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST; +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST_DUAL; + +public class BaseQuranInfo { + public static int[] SURA_PAGE_START = QuranData.SURA_PAGE_START; + public static int[] PAGE_SURA_START = QuranData.PAGE_SURA_START; + public static int[] PAGE_AYAH_START = QuranData.PAGE_AYAH_START; + public static int[] JUZ_PAGE_START = QuranData.JUZ_PAGE_START; + public static int[] PAGE_RUB3_START = QuranData.PAGE_RUB3_START; + public static int[] SURA_NUM_AYAHS = QuranData.SURA_NUM_AYAHS; + public static boolean[] SURA_IS_MAKKI = QuranData.SURA_IS_MAKKI; + public static int[][] QUARTERS = QuranData.QUARTERS; + + /** + * Get localized sura name from resources + * + * @param context Application context + * @param sura Sura number (1~114) + * @param wantPrefix Whether or not to show prefix "Sura" + * @return Compiled sura name without translations + */ + public static String getSuraName(Context context, int sura, boolean wantPrefix) { + return getSuraName(context, sura, wantPrefix, false); + } + + /** + * Get localized sura name from resources + * + * @param context Application context + * @param sura Sura number (1~114) + * @param wantPrefix Whether or not to show prefix "Sura" + * @param wantTranslation Whether or not to show sura name translations + * @return Compiled sura name based on provided arguments + */ + public static String getSuraName(Context context, int sura, + boolean wantPrefix, boolean wantTranslation) { + if (sura < Constants.SURA_FIRST || + sura > Constants.SURA_LAST) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + String[] suraNames = context.getResources().getStringArray(R.array.sura_names); + if (wantPrefix) { + builder.append(context.getString(R.string.quran_sura_title, suraNames[sura - 1])); + } else { + builder.append(suraNames[sura - 1]); + } + if (wantTranslation) { + String translation = context.getResources().getStringArray(R.array.sura_names_translation)[sura - 1]; + if (!TextUtils.isEmpty(translation)) { + // Some sura names may not have translation + builder.append(" ("); + builder.append(translation); + builder.append(")"); + } + } + + return builder.toString(); + } + + public static int getSuraNumberFromPage(int page) { + int sura = -1; + for (int i = 0; i < Constants.SURAS_COUNT; i++) { + if (SURA_PAGE_START[i] == page) { + sura = i + 1; + break; + } else if (SURA_PAGE_START[i] > page) { + sura = i; + break; + } + } + + return sura; + } + + public static String getSuraNameFromPage(Context context, int page, + boolean wantTitle) { + int sura = getSuraNumberFromPage(page); + return (sura > 0) ? getSuraName(context, sura, wantTitle, false) : ""; + } + + public static String getPageSubtitle(Context context, int page) { + String description = context.getString(R.string.page_description); + return String.format(description, + QuranUtils.getLocalizedNumber(context, page), + QuranUtils.getLocalizedNumber(context, + QuranInfo.getJuzFromPage(page))); + } + + public static String getJuzString(Context context, int page) { + String description = context.getString(R.string.juz2_description); + return String.format(description, QuranUtils.getLocalizedNumber( + context, QuranInfo.getJuzFromPage(page))); + } + + public static String getSuraAyahString(Context context, int sura, int ayah) { + String suraName = getSuraName(context, sura, false, false); + return context.getString(R.string.sura_ayah_notification_str, suraName, ayah); + } + + public static String getNotificationTitle(Context context, + SuraAyah minVerse, + SuraAyah maxVerse, + boolean isGapless) { + int minSura = minVerse.sura; + int maxSura = maxVerse.sura; + + String notificationTitle = + QuranInfo.getSuraName(context, minSura, true, false); + if (isGapless) { + // for gapless, don't show the ayah numbers since we're + // downloading the entire sura(s). + if (minSura == maxSura) { + return notificationTitle; + } else { + return notificationTitle + " - " + + QuranInfo.getSuraName(context, maxSura, true, false); + } + } + + int maxAyah = maxVerse.ayah; + if (maxAyah == 0) { + maxSura--; + maxAyah = QuranInfo.getNumAyahs(maxSura); + } + + if (minSura == maxSura) { + if (minVerse.ayah == maxAyah) { + notificationTitle += " (" + maxAyah + ")"; + } else { + notificationTitle += " (" + minVerse.ayah + + "-" + maxAyah + ")"; + } + } else { + notificationTitle += " (" + minVerse.ayah + + ") - " + QuranInfo.getSuraName(context, maxSura, true, false) + + " (" + maxAyah + ")"; + } + + return notificationTitle; + } + + public static String getSuraListMetaString(Context context, int sura) { + String info = context.getString(QuranInfo.SURA_IS_MAKKI[sura - 1] + ? R.string.makki : R.string.madani) + " - "; + + int ayahs = QuranInfo.SURA_NUM_AYAHS[sura - 1]; + info += context.getResources().getQuantityString(R.plurals.verses, ayahs, + QuranUtils.getLocalizedNumber(context, ayahs)); + return info; + } + + public static VerseRange getVerseRangeForPage(int page) { + int[] result = getPageBounds(page); + return new VerseRange(result[0], result[1], result[2], result[3]); + } + + @NonNull + public static int[] getPageBounds(int page) { + if (page > PAGES_LAST) + page = PAGES_LAST; + if (page < 1) page = 1; + + int[] bounds = new int[4]; + bounds[0] = PAGE_SURA_START[page - 1]; + bounds[1] = PAGE_AYAH_START[page - 1]; + if (page == PAGES_LAST) { + bounds[2] = Constants.SURA_LAST; + bounds[3] = 6; + } else { + int nextPageSura = PAGE_SURA_START[page]; + int nextPageAyah = PAGE_AYAH_START[page]; + + if (nextPageSura == bounds[0]) { + bounds[2] = bounds[0]; + bounds[3] = nextPageAyah - 1; + } else { + if (nextPageAyah > 1) { + bounds[2] = nextPageSura; + bounds[3] = nextPageAyah - 1; + } else { + bounds[2] = nextPageSura - 1; + bounds[3] = SURA_NUM_AYAHS[bounds[2] - 1]; + } + } + } + return bounds; + } + + public static int safelyGetSuraOnPage(int page) { + if (page < PAGES_FIRST || page > PAGES_LAST) { + Crashlytics.logException(new IllegalArgumentException("got page: " + page)); + page = 1; + } + return PAGE_SURA_START[page - 1]; + } + + public static String getSuraNameFromPage(Context context, int page) { + for (int i = 0; i < Constants.SURAS_COUNT; i++) { + if (SURA_PAGE_START[i] == page) { + return getSuraName(context, i + 1, false, false); + } else if (SURA_PAGE_START[i] > page) { + return getSuraName(context, i, false, false); + } + } + return ""; + } + + public static int getJuzFromPage(int page) { + int juz = ((page - 2) / 20) + 1; + return juz > 30 ? 30 : juz < 1 ? 1 : juz; + } + + public static int getRub3FromPage(int page) { + if ((page > PAGES_LAST) || (page < 1)) return -1; + return PAGE_RUB3_START[page - 1]; + } + + public static int getPageFromSuraAyah(int sura, int ayah) { + // basic bounds checking + if (ayah == 0) ayah = 1; + if ((sura < 1) || (sura > Constants.SURAS_COUNT) + || (ayah < Constants.AYA_MIN) || + (ayah > Constants.AYA_MAX)) + return -1; + + // what page does the sura start on? + int index = QuranInfo.SURA_PAGE_START[sura - 1] - 1; + while (index < PAGES_LAST) { + // what's the first sura in that page? + int ss = QuranInfo.PAGE_SURA_START[index]; + + // if we've passed the sura, return the previous page + // or, if we're at the same sura and passed the ayah + if (ss > sura || ((ss == sura) && + (QuranInfo.PAGE_AYAH_START[index] > ayah))) { + break; + } + + // otherwise, look at the next page + index++; + } + + return index; + } + + public static int getAyahId(int sura, int ayah) { + int ayahId = 0; + for (int i = 0; i < sura - 1; i++) { + ayahId += SURA_NUM_AYAHS[i]; + } + ayahId += ayah; + return ayahId; + } + + public static int getNumAyahs(int sura) { + if ((sura < 1) || (sura > Constants.SURAS_COUNT)) return -1; + return SURA_NUM_AYAHS[sura - 1]; + } + + public static int getPageFromPos(int position, boolean dual) { + int page = PAGES_LAST - position; + if (dual) { + page = (PAGES_LAST_DUAL - position) * 2; + } + return page; + } + + public static int getPosFromPage(int page, boolean dual) { + int position = PAGES_LAST - page; + if (dual) { + if (page % 2 != 0) { + page++; + } + position = PAGES_LAST_DUAL - (page / 2); + } + return position; + } + + public static String getAyahString(int sura, int ayah, Context context) { + return getSuraName(context, sura, true) + " - " + context.getString(R.string.quran_ayah, ayah); + } + + public static String getAyahMetadata(int sura, int ayah, int page, Context context) { + int juz = getJuzFromPage(page); + return context.getString(R.string.quran_ayah_details, getSuraName(context, sura, true), + QuranUtils.getLocalizedNumber(context, ayah), QuranUtils.getLocalizedNumber(context, juz)); + } + + public static String getSuraNameString(Context context, int page) { + return context.getString(R.string.quran_sura_title, getSuraNameFromPage(context, page)); + } + + public static Set getAyahKeysOnPage(int page, SuraAyah lowerBound, SuraAyah upperBound) { + Set ayahKeys = new LinkedHashSet<>(); + int[] bounds = QuranInfo.getPageBounds(page); + SuraAyah start = new SuraAyah(bounds[0], bounds[1]); + SuraAyah end = new SuraAyah(bounds[2], bounds[3]); + if (lowerBound != null) { + start = SuraAyah.max(start, lowerBound); + } + if (upperBound != null) { + end = SuraAyah.min(end, upperBound); + } + SuraAyahIterator iterator = new SuraAyahIterator(start, end); + while (iterator.next()) { + ayahKeys.add(iterator.getSura() + ":" + iterator.getAyah()); + } + return ayahKeys; + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/Constants.java b/app/src/main/java/com/quran/labs/androidquran/data/Constants.java new file mode 100644 index 0000000000..44bd0c7266 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/Constants.java @@ -0,0 +1,78 @@ +package com.quran.labs.androidquran.data; + +public class Constants { + + // data domain + public static final String HOST = "http://android.quran.com/"; + + // Numerics + public static final int DEFAULT_NIGHT_MODE_TEXT_BRIGHTNESS = 255; + public static final int DEFAULT_TEXT_SIZE = 15; + + // 10 days in ms + public static final int TRANSLATION_REFRESH_TIME = 60 * 60 * 24 * 10 * 1000; + + // 1 hour in ms + public static final int MIN_TRANSLATION_REFRESH_TIME = 60 * 60 * 1000; + + // Pages + public static final int PAGES_FIRST = 1; + public static final int PAGES_LAST = QuranConstants.NUMBER_OF_PAGES; + public static final int PAGES_LAST_DUAL = PAGES_LAST / 2; + public static final int SURA_FIRST = 1; + public static final int SURA_LAST = 114; + public static final int SURAS_COUNT = 114; + public static final int JUZ2_COUNT = 30; + public static final int AYA_MIN = 1; + public static final int AYA_MAX = 286; + public static final int NO_PAGE = -1; + public static final int MAX_RECENT_PAGES = 3; + + // quranapp + public static final String QURAN_APP_BASE = "http://quranapp.com/"; + public static final String QURAN_APP_ENDPOINT = "http://quranapp.com/note"; + + // Settings Key (some of these have corresponding values in preference_keys.xml) + public static final String PREF_APP_LOCATION = "appLocation"; + public static final String PREF_USE_ARABIC_NAMES = "useArabicNames"; + public static final String PREF_LAST_PAGE = "lastPage"; + public static final String PREF_LOCK_ORIENTATION = "lockOrientation"; + public static final String PREF_LANDSCAPE_ORIENTATION = + "landscapeOrientation"; + public static final String PREF_TRANSLATION_TEXT_SIZE = "translationTextSize"; + public static final String PREF_ACTIVE_TRANSLATION = "activeTranslation"; + public static final String PREF_ACTIVE_TRANSLATIONS = "activeTranslations"; + public static final String PREF_NIGHT_MODE = "nightMode"; + public static final String PREF_NIGHT_MODE_TEXT_BRIGHTNESS = "nightModeTextBrightness"; + public static final String PREF_DEFAULT_QARI = "defaultQari"; + public static final String PREF_SHOULD_FETCH_PAGES = "shouldFetchPages"; + public static final String PREF_OVERLAY_PAGE_INFO = "overlayPageInfo"; + public static final String PREF_DISPLAY_MARKER_POPUP = "displayMarkerPopup"; + public static final String PREF_HIGHLIGHT_BOOKMARKS = "highlightBookmarks"; + public static final String PREF_AYAH_BEFORE_TRANSLATION = + "ayahBeforeTranslation"; + public static final String PREF_PREFER_STREAMING = "preferStreaming"; + public static final String PREF_DOWNLOAD_AMOUNT = "preferredDownloadAmount"; + public static final String PREF_LAST_UPDATED_TRANSLATIONS = + "lastTranslationsUpdate"; + public static final String PREF_HAVE_UPDATED_TRANSLATIONS = + "haveUpdatedTranslations"; + public static final String PREF_USE_NEW_BACKGROUND = "useNewBackground"; + public static final String PREF_USE_VOLUME_KEY_NAV = "volumeKeyNavigation"; + public static final String PREF_SORT_BOOKMARKS = "sortBookmarks"; + public static final String PREF_GROUP_BOOKMARKS_BY_TAG = "groupBookmarksByTag"; + public static final String PREF_SHOW_RECENTS = "showRecents"; + public static final String PREF_DISPLAY_CATEGORY = "displayCategoryKey"; + public static final String PREF_TABLET_ENABLED = "useTabletMode"; + public static final String PREF_VERSION = "version"; + public static final String PREF_DEFAULT_IMAGES_DIR = "defaultImagesDir"; + public static final String PREF_TRANSLATION_MANAGER = "translationManagerKey"; + public static final String PREF_AUDIO_MANAGER = "audioManagerKey"; + public static final String PREF_IMPORT = "importKey"; + public static final String PREF_EXPORT = "exportKey"; + public static final String PREF_LOGS = "sendLogsKey"; + public static final String PREF_DID_PRESENT_PERMISSIONS_DIALOG = + "didPresentStoragePermissionDialog"; + public static final String PREF_WAS_SHOWING_TRANSLATION = "wasShowingTranslation"; + public static final String PREF_QURAN_SETTINGS = "quranSettings"; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java b/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java new file mode 100644 index 0000000000..d253740e4b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java @@ -0,0 +1,324 @@ +package com.quran.labs.androidquran.data; + +import android.app.SearchManager; +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.BuildConfig; +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.database.DatabaseHandler; +import com.quran.labs.androidquran.database.DatabaseUtils; +import com.quran.labs.androidquran.database.TranslationsDBAdapter; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.util.TranslationUtils; + +import java.util.List; + +import javax.inject.Inject; + +import timber.log.Timber; + +public class QuranDataProvider extends ContentProvider { + + public static String AUTHORITY = BuildConfig.APPLICATION_ID + ".data.QuranDataProvider"; + public static final Uri SEARCH_URI = Uri.parse("content://" + AUTHORITY + "/quran/search"); + public static final Uri SEARCH_ADVANCED_URI = Uri.parse("content://" + AUTHORITY + "/quran/searchAdvanced"); + + public static final String VERSES_MIME_TYPE = + ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.com.quran.labs.androidquran"; + public static final String AYAH_MIME_TYPE = + ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.com.quran.labs.androidquran"; + public static final String QURAN_ARABIC_DATABASE = QuranFileConstants.ARABIC_DATABASE; + + // UriMatcher stuff + private static final int SEARCH_VERSES = 0; + private static final int GET_VERSE = 1; + private static final int SEARCH_SUGGEST = 2; + private static final int SEARCH_ADVANCED = 3; + private static final UriMatcher uriMatcher = buildUriMatcher(); + + private boolean didInject; + @Inject QuranSettings quranSettings; + @Inject TranslationsDBAdapter translationsDBAdapter; + + private static UriMatcher buildUriMatcher() { + UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH); + matcher.addURI(AUTHORITY, "quran/search", SEARCH_VERSES); + matcher.addURI(AUTHORITY, "quran/search/*", SEARCH_VERSES); + matcher.addURI(AUTHORITY, "quran/search/*/*", SEARCH_VERSES); + matcher.addURI(AUTHORITY, "quran/verse/#/#", GET_VERSE); + matcher.addURI(AUTHORITY, "quran/verse/*/#/#", GET_VERSE); + matcher.addURI(AUTHORITY, "quran/searchAdvanced", SEARCH_ADVANCED); + matcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY, + SEARCH_SUGGEST); + matcher.addURI(AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", + SEARCH_SUGGEST); + return matcher; + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + Context context = getContext(); + if (!didInject) { + Context appContext = context == null ? null : context.getApplicationContext(); + if (appContext instanceof QuranApplication) { + ((QuranApplication) appContext).getApplicationComponent().inject(this); + didInject = true; + } else { + Timber.e("unable to inject QuranDataProvider"); + return null; + } + } + + Crashlytics.log("uri: " + uri.toString()); + switch (uriMatcher.match(uri)) { + case SEARCH_SUGGEST: { + if (selectionArgs == null) { + throw new IllegalArgumentException( + "selectionArgs must be provided for the Uri: " + uri); + } + + return getSuggestions(selectionArgs[0]); + } + case SEARCH_VERSES: { + if (selectionArgs == null) { + throw new IllegalArgumentException( + "selectionArgs must be provided for the Uri: " + uri); + } + + if (selectionArgs.length == 1) { + return search(selectionArgs[0]); + } else { + return search(selectionArgs[0], selectionArgs[1], true); + } + } + case GET_VERSE: { + return getVerse(uri); + } + case SEARCH_ADVANCED: { + if (selectionArgs == null) { + throw new IllegalArgumentException( + "selectionArgs must be provided for the Uri: " + uri); + } + + if (selectionArgs.length >= 1) { + return searchAdvanced(selectionArgs); + } + } + default: { + throw new IllegalArgumentException("Unknown Uri: " + uri); + } + } + } + + private Cursor search(String query) { + if (QuranUtils.doesStringContainArabic(query) && + QuranFileUtils.hasTranslation(getContext(), QURAN_ARABIC_DATABASE)) { + Cursor c = search(query, QURAN_ARABIC_DATABASE, true); + if (c != null) { + return c; + } + } + + String active = getActiveTranslation(); + if (TextUtils.isEmpty(active)) { + return null; + } + return search(query, active, true); + } + + private Cursor searchAdvanced(String[] query) { + if (QuranUtils.doesStringContainArabic(query[0]) && + QuranFileUtils.hasTranslation(getContext(), QURAN_ARABIC_DATABASE)) { + // Cursor c = search(query, QURAN_ARABIC_DATABASE, true); + final DatabaseHandler handler = DatabaseHandler.getDatabaseHandler(getContext(), QURAN_ARABIC_DATABASE); + return handler.searchAdvanced(query); + +// if (c != null) { +// return c; +// } + } +return null; +// String active = getActiveTranslation(); +// if (TextUtils.isEmpty(active)) { +// return null; +// } +// return search(query, active, true); + } + private String getActiveTranslation() { + String db = quranSettings.getActiveTranslation(); + if (!TextUtils.isEmpty(db)) { + if (QuranFileUtils.hasTranslation(getContext(), db)) { + return db; + } + // our active database no longer exists, remove the pref + quranSettings.removeActiveTranslation(); + } + + try { + Crashlytics.log("couldn't find database, searching for another.."); + List items = translationsDBAdapter.getTranslations(); + if (items != null && items.size() > 0) { + return TranslationUtils.getDefaultTranslation(getContext(), items); + } + } catch (Exception e) { + Crashlytics.logException(e); + } + return null; + } + + private Cursor getSuggestions(String query) { + if (query.length() < 3) { + return null; + } + + boolean haveArabic = false; + if (QuranUtils.doesStringContainArabic(query) && + QuranFileUtils.hasTranslation(getContext(), QURAN_ARABIC_DATABASE)) { + haveArabic = true; + } + + boolean haveTranslation = false; + String active = getActiveTranslation(); + if (!TextUtils.isEmpty(active)) { + haveTranslation = true; + } + + int numItems = (haveArabic ? 1 : 0) + (haveTranslation ? 1 : 0); + if (numItems == 0) { + return null; + } + + String[] items = new String[numItems]; + if (haveArabic) { + items[0] = QURAN_ARABIC_DATABASE; + } + + if (haveTranslation) { + items[numItems - 1] = active; + } + + String[] cols = new String[] { BaseColumns._ID, + SearchManager.SUGGEST_COLUMN_TEXT_1, + SearchManager.SUGGEST_COLUMN_TEXT_2, + SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID }; + MatrixCursor mc = new MatrixCursor(cols); + + Context context = getContext(); + boolean gotResults = false; + for (String item : items) { + if (gotResults) { + continue; + } + + Cursor suggestions = null; + try { + suggestions = search(query, item, false); + if (suggestions != null && suggestions.moveToFirst()) { + do { + int sura = suggestions.getInt(1); + int ayah = suggestions.getInt(2); + String text = suggestions.getString(3); + String foundText = context.getString( + R.string.found_in_sura, QuranInfo.getSuraName(context, sura, false), ayah); + + gotResults = true; + MatrixCursor.RowBuilder row = mc.newRow(); + int id = suggestions.getInt(0); + + row.add(id); + row.add(text); + row.add(foundText); + row.add(id); + } while (suggestions.moveToNext()); + } + } finally { + DatabaseUtils.closeCursor(suggestions); + } + } + + return mc; + } + + private Cursor search(String query, String language, boolean wantSnippets) { + Timber.d("q: %s, l: %s", query, language); + if (language == null) { + return null; + } + + final DatabaseHandler handler = DatabaseHandler.getDatabaseHandler(getContext(), language); + return handler.search(query, wantSnippets); + } + + private Cursor getVerse(Uri uri) { + int sura = 1; + int ayah = 1; + String langType = getActiveTranslation(); + String lang = (TextUtils.isEmpty(langType)) ? null : langType; + if (lang == null) { + return null; + } + + List parts = uri.getPathSegments(); + for (String s : parts) { + Timber.d("uri part: %s", s); + } + + final DatabaseHandler handler = DatabaseHandler.getDatabaseHandler(getContext(), lang); + return handler.getVerse(sura, ayah); + } + + @Override + public String getType(@NonNull Uri uri) { + switch (uriMatcher.match(uri)) { + case SEARCH_VERSES: { + return VERSES_MIME_TYPE; + } + case GET_VERSE: { + return AYAH_MIME_TYPE; + } + case SEARCH_SUGGEST: { + return SearchManager.SUGGEST_MIME_TYPE; + } + default: { + throw new IllegalArgumentException("Unknown URL " + uri); + } + } + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/SuraAyah.java b/app/src/main/java/com/quran/labs/androidquran/data/SuraAyah.java new file mode 100644 index 0000000000..3a7ab1b547 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/SuraAyah.java @@ -0,0 +1,87 @@ +package com.quran.labs.androidquran.data; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +public class SuraAyah implements Comparable, Parcelable { + public final int sura; + public final int ayah; + private int page = -1; + + public SuraAyah(int sura, int ayah) { + this.sura = sura; + this.ayah = ayah; + } + + public SuraAyah(Parcel parcel) { + this.sura = parcel.readInt(); + this.ayah = parcel.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(sura); + dest.writeInt(ayah); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public SuraAyah createFromParcel(Parcel in) { + return new SuraAyah(in); + } + + public SuraAyah[] newArray(int size) { + return new SuraAyah[size]; + } + }; + + public int getPage() { + return page > 0 ? page : (page = QuranInfo.getPageFromSuraAyah(sura, ayah)); + } + + @Override + public int compareTo(@NonNull SuraAyah another) { + if (this.equals(another)) { + return 0; + } else if (sura == another.sura) { + return ayah < another.ayah ? -1 : 1; + } else { + return sura < another.sura ? -1 : 1; + } + } + + @Override + public boolean equals(Object o) { + return o != null && o.getClass() == SuraAyah.class && + ((SuraAyah) o).sura == sura && ((SuraAyah) o).ayah == ayah; + } + + @Override + public int hashCode() { + return 31 * sura + ayah; + } + + @Override + public String toString() { + return "(" + sura + ":" + ayah + ")"; + } + + public static SuraAyah min(SuraAyah a, SuraAyah b) { + return a.compareTo(b) <= 0 ? a : b; + } + + public static SuraAyah max(SuraAyah a, SuraAyah b) { + return a.compareTo(b) >= 0 ? a : b; + } + + public boolean after(SuraAyah next) { + return sura > next.sura || (sura == next.sura && ayah > next.ayah); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/SuraAyahIterator.java b/app/src/main/java/com/quran/labs/androidquran/data/SuraAyahIterator.java new file mode 100644 index 0000000000..7d0c7752e0 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/SuraAyahIterator.java @@ -0,0 +1,56 @@ +package com.quran.labs.androidquran.data; + +public class SuraAyahIterator { + + private SuraAyah start; + private SuraAyah end; + + private boolean started; + private int curSura; + private int curAyah; + + public SuraAyahIterator(SuraAyah start, SuraAyah end) { + // Sanity check + if (start.compareTo(end) <= 0) { + this.start = start; + this.end = end; + } else { + this.start = end; + this.end = start; + } + reset(); + } + + private void reset() { + curSura = start.sura; + curAyah = start.ayah; + started = false; + } + + public int getSura() { + return curSura; + } + + public int getAyah() { + return curAyah; + } + + private boolean hasNext() { + return !started || curSura < end.sura || curAyah < end.ayah; + } + + public boolean next() { + if (!started) { + return started = true; + } else if (!hasNext()) { + return false; + } + if (curAyah < QuranInfo.getNumAyahs(curSura)) { + curAyah++; + } else { + curAyah = 1; + curSura++; + } + return true; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/data/VerseRange.java b/app/src/main/java/com/quran/labs/androidquran/data/VerseRange.java new file mode 100644 index 0000000000..4e35a2a108 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/data/VerseRange.java @@ -0,0 +1,20 @@ +package com.quran.labs.androidquran.data; + +public class VerseRange { + public final int startSura; + public final int startAyah; + public final int endingSura; + public final int endingAyah; + public final int versesInRange; + + public VerseRange(int startSura, int startAyah, int endingSura, int endingAyah) { + this.startSura = startSura; + this.startAyah = startAyah; + this.endingSura = endingSura; + this.endingAyah = endingAyah; + int delta = QuranInfo.getAyahId(endingSura, endingAyah) - + QuranInfo.getAyahId(startSura, startAyah); + // adding 1 because in the case of a single ayah, there is 1 ayah in that range, not 0 + versesInRange = 1 + (delta > 0 ? delta : -delta); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/BookmarksDBAdapter.java b/app/src/main/java/com/quran/labs/androidquran/database/BookmarksDBAdapter.java new file mode 100644 index 0000000000..cfc3911386 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/BookmarksDBAdapter.java @@ -0,0 +1,406 @@ +package com.quran.labs.androidquran.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.NonNull; +import android.support.v4.util.Pair; + +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.dao.BookmarkData; +import com.quran.labs.androidquran.dao.RecentPage; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.database.BookmarksDBHelper.BookmarkTagTable; +import com.quran.labs.androidquran.database.BookmarksDBHelper.BookmarksTable; +import com.quran.labs.androidquran.database.BookmarksDBHelper.LastPagesTable; +import com.quran.labs.androidquran.database.BookmarksDBHelper.TagsTable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import timber.log.Timber; + +public class BookmarksDBAdapter { + + public static final int SORT_DATE_ADDED = 0; + public static final int SORT_LOCATION = 1; + private static final int SORT_ALPHABETICAL = 2; + + private SQLiteDatabase mDb; + + public BookmarksDBAdapter(Context context) { + BookmarksDBHelper dbHelper = BookmarksDBHelper.getInstance(context); + mDb = dbHelper.getWritableDatabase(); + } + + @NonNull + public List getBookmarkedAyahsOnPage(int page) { + return getBookmarks(SORT_LOCATION, page); + } + + @NonNull + public List getBookmarks(int sortOrder) { + return getBookmarks(sortOrder, null); + } + + @NonNull + private List getBookmarks(int sortOrder, Integer pageFilter) { + String orderBy; + switch (sortOrder) { + case SORT_LOCATION: + orderBy = BookmarksTable.PAGE + " ASC, " + + BookmarksTable.SURA + " ASC, " + BookmarksTable.AYAH + " ASC"; + break; + case SORT_DATE_ADDED: + default: + orderBy = BookmarksTable.TABLE_NAME + "." + BookmarksTable.ADDED_DATE + " DESC"; + } + + List bookmarks = new ArrayList<>(); + StringBuilder queryBuilder = new StringBuilder(BookmarksDBHelper.QUERY_BOOKMARKS); + if (pageFilter != null) { + queryBuilder.append(" WHERE ") + .append(BookmarksTable.PAGE) + .append(" = ").append(pageFilter).append(" AND ") + .append(BookmarksTable.SURA).append(" IS NOT NULL").append(" AND ") + .append(BookmarksTable.AYAH).append(" IS NOT NULL"); + } + queryBuilder.append(" ORDER BY ").append(orderBy); + + Cursor cursor = null; + try { + cursor = mDb.rawQuery(queryBuilder.toString(), null); + if (cursor != null) { + long lastId = -1; + Bookmark lastBookmark = null; + List tagIds = new ArrayList<>(); + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + Integer sura = cursor.getInt(1); + Integer ayah = cursor.getInt(2); + int page = cursor.getInt(3); + long time = cursor.getLong(4); + long tagId = cursor.getLong(5); + + if (sura == 0 || ayah == 0) { + sura = null; + ayah = null; + } + + if (lastId != id) { + if (lastBookmark != null) { + bookmarks.add(lastBookmark.withTags(tagIds)); + } + tagIds.clear(); + lastBookmark = new Bookmark(id, sura, ayah, page, time); + lastId = id; + } + + if (tagId > 0) { + tagIds.add(tagId); + } + } + + if (lastBookmark != null) { + bookmarks.add(lastBookmark.withTags(tagIds)); + } + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + + return bookmarks; + } + + @NonNull + public List getRecentPages() { + List recents = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = mDb.query(LastPagesTable.TABLE_NAME, null, null, null, null, null, + LastPagesTable.ADDED_DATE + " DESC"); + if (cursor != null) { + while (cursor.moveToNext()) { + recents.add(new RecentPage(cursor.getInt(1), cursor.getLong(2))); + } + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + return recents; + } + + public boolean replaceRecentRangeWithPage(int deleteRangeStart, int deleteRangeEnd, int page) { + int count = mDb.delete(LastPagesTable.TABLE_NAME, + LastPagesTable.PAGE + " >= ? AND " + LastPagesTable.PAGE + " <= ?", + new String[] { String.valueOf(deleteRangeStart), String.valueOf(deleteRangeEnd) }); + // avoid doing a delete if this delete caused us to remove any rows + boolean shouldDelete = count == 0; + return addRecentPage(page, shouldDelete); + } + + public boolean addRecentPage(int page) { + return addRecentPage(page, true); + } + + private boolean addRecentPage(int page, boolean shouldDelete) { + ContentValues contentValues = new ContentValues(); + contentValues.put(LastPagesTable.PAGE, page); + if (mDb.replace(LastPagesTable.TABLE_NAME, null, contentValues) != -1 && shouldDelete) { + mDb.execSQL("DELETE FROM " + LastPagesTable.TABLE_NAME + " WHERE " + + LastPagesTable.ID + " NOT IN( SELECT " + LastPagesTable.ID + " FROM " + + LastPagesTable.TABLE_NAME + " ORDER BY " + LastPagesTable.ADDED_DATE + " DESC LIMIT ? )", + new Object[] { Constants.MAX_RECENT_PAGES }); + return true; + } + return false; + } + + @NonNull + public List getBookmarkTagIds(long bookmarkId) { + List bookmarkTags = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = mDb.query(BookmarkTagTable.TABLE_NAME, + new String[]{BookmarkTagTable.TAG_ID}, + BookmarkTagTable.BOOKMARK_ID + "=" + bookmarkId, + null, null, null, BookmarkTagTable.TAG_ID + " ASC"); + if (cursor != null) { + while (cursor.moveToNext()) { + bookmarkTags.add(cursor.getLong(0)); + } + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + return bookmarkTags; + } + + public long getBookmarkId(Integer sura, Integer ayah, int page) { + Cursor cursor = null; + try { + cursor = mDb.query(BookmarksTable.TABLE_NAME, + null, BookmarksTable.PAGE + "=" + page + " AND " + + BookmarksTable.SURA + (sura == null ? " IS NULL" : "=" + sura) + + " AND " + BookmarksTable.AYAH + + (ayah == null ? " IS NULL" : "=" + ayah), null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(0); + } + } catch (Exception e) { + // swallow the error for now + } finally { + DatabaseUtils.closeCursor(cursor); + } + return -1; + } + + public void bulkDelete(List tagIds, List bookmarkIds, List> untag) { + mDb.beginTransaction(); + try { + // cache and re-use for tags and bookmarks + String[] param = new String[1]; + + // remove tags + for (int i = 0, tagIdsSize = tagIds.size(); i < tagIdsSize; i++) { + long tagId = tagIds.get(i); + param[0] = String.valueOf(tagId); + mDb.delete(TagsTable.TABLE_NAME, TagsTable.ID + " = ?", param); + mDb.delete(BookmarkTagTable.TABLE_NAME, BookmarkTagTable.TAG_ID + " = ?", param); + } + + // remove bookmarks + for (int i = 0, bookmarkIdsSize = bookmarkIds.size(); i < bookmarkIdsSize; i++) { + long bookmarkId = bookmarkIds.get(i); + param[0] = String.valueOf(bookmarkId); + mDb.delete(BookmarksTable.TABLE_NAME, BookmarksTable.ID + " = ?", param); + mDb.delete(BookmarkTagTable.TABLE_NAME, BookmarkTagTable.BOOKMARK_ID + " = ?", param); + } + + // untag whatever is being untagged + String[] params = new String[2]; + for (int i = 0, untagSize = untag.size(); i < untagSize; i++) { + Pair item = untag.get(i); + params[0] = String.valueOf(item.first); + params[1] = String.valueOf(item.second); + mDb.delete(BookmarkTagTable.TABLE_NAME, + BookmarkTagTable.BOOKMARK_ID + " = ? AND " + BookmarkTagTable.TAG_ID + " = ?", params); + } + mDb.setTransactionSuccessful(); + } finally { + mDb.endTransaction(); + } + } + + public long addBookmarkIfNotExists(Integer sura, Integer ayah, int page) { + long bookmarkId = getBookmarkId(sura, ayah, page); + if (bookmarkId < 0) { + bookmarkId = addBookmark(sura, ayah, page); + } + return bookmarkId; + } + + public long addBookmark(Integer sura, Integer ayah, int page) { + ContentValues values = new ContentValues(); + values.put(BookmarksTable.SURA, sura); + values.put(BookmarksTable.AYAH, ayah); + values.put(BookmarksTable.PAGE, page); + return mDb.insert(BookmarksTable.TABLE_NAME, null, values); + } + + public boolean removeBookmark(long bookmarkId) { + mDb.delete(BookmarkTagTable.TABLE_NAME, + BookmarkTagTable.BOOKMARK_ID + "=" + bookmarkId, null); + return mDb.delete(BookmarksTable.TABLE_NAME, + BookmarksTable.ID + "=" + bookmarkId, null) == 1; + } + + @NonNull + public List getTags() { + return getTags(SORT_ALPHABETICAL); + } + + @NonNull + private List getTags(int sortOrder) { + String orderBy; + switch (sortOrder) { + case SORT_DATE_ADDED: + orderBy = TagsTable.ADDED_DATE + " DESC"; + break; + case SORT_ALPHABETICAL: + default: + orderBy = TagsTable.NAME + " ASC"; + break; + } + List tags = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = mDb.query(TagsTable.TABLE_NAME, + null, null, null, null, null, orderBy); + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + String name = cursor.getString(1); + Tag tag = new Tag(id, name); + tags.add(tag); + } + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + return tags; + } + + public long addTag(String name) { + ContentValues values = new ContentValues(); + values.put(TagsTable.NAME, name); + return mDb.insert(TagsTable.TABLE_NAME, null, values); + } + + public boolean updateTag(long id, String newName) { + ContentValues values = new ContentValues(); + values.put(TagsTable.ID, id); + values.put(TagsTable.NAME, newName); + return 1 == mDb.update(TagsTable.TABLE_NAME, values, + TagsTable.ID + "=" + id, null); + } + + /** + * Tag a list of bookmarks with a list of tags. + * @param bookmarkIds the list of bookmark ids to tag. + * @param tagIds the tags to tag those bookmarks with. + * @param deleteNonTagged whether or not we should delete all tags not in tagIds from those + * bookmarks or not. + * @return a boolean denoting success + */ + public boolean tagBookmarks(long[] bookmarkIds, Set tagIds, boolean deleteNonTagged) { + mDb.beginTransaction(); + try { + // if we're literally replacing the tags such that only tagIds are tagged, then we need to + // remove all tags from the various bookmarks first. + if (deleteNonTagged) { + for (long bookmarkId : bookmarkIds) { + mDb.delete(BookmarkTagTable.TABLE_NAME, + BookmarkTagTable.BOOKMARK_ID + "=" + bookmarkId, null); + } + } + + for (Long tagId : tagIds) { + for (long bookmarkId : bookmarkIds) { + ContentValues values = new ContentValues(); + values.put(BookmarkTagTable.BOOKMARK_ID, bookmarkId); + values.put(BookmarkTagTable.TAG_ID, tagId); + mDb.replace(BookmarkTagTable.TABLE_NAME, null, values); + } + } + mDb.setTransactionSuccessful(); + return true; + } catch (Exception e) { + Timber.d(e, "exception in tagBookmark"); + return false; + } finally { + mDb.endTransaction(); + } + } + + public boolean importBookmarks(BookmarkData data) { + boolean result = true; + mDb.beginTransaction(); + try { + mDb.delete(BookmarksTable.TABLE_NAME, null, null); + mDb.delete(BookmarkTagTable.TABLE_NAME, null, null); + mDb.delete(TagsTable.TABLE_NAME, null, null); + + ContentValues values = new ContentValues(); + + List tags = data.getTags(); + int tagSize = tags.size(); + Timber.d("importing %d tags...", tagSize); + + for (int i = 0; i < tagSize; i++) { + Tag tag = tags.get(i); + values.clear(); + values.put(TagsTable.NAME, tag.name); + values.put(TagsTable.ID, tag.id); + mDb.insert(TagsTable.TABLE_NAME, null, values); + } + + List bookmarks = data.getBookmarks(); + int bookmarkSize = bookmarks.size(); + Timber.d("importing %d bookmarks...", bookmarkSize); + + for (int i = 0; i < bookmarkSize; i++) { + Bookmark bookmark = bookmarks.get(i); + + values.clear(); + values.put(BookmarksTable.ID, bookmark.id); + values.put(BookmarksTable.SURA, bookmark.sura); + values.put(BookmarksTable.AYAH, bookmark.ayah); + values.put(BookmarksTable.PAGE, bookmark.page); + values.put(BookmarksTable.ADDED_DATE, bookmark.timestamp); + mDb.insert(BookmarksTable.TABLE_NAME, null, values); + + List tagIds = bookmark.tags; + for (int t = 0; t < tagIds.size(); t++) { + values.clear(); + values.put(BookmarkTagTable.BOOKMARK_ID, bookmark.id); + values.put(BookmarkTagTable.TAG_ID, tagIds.get(t)); + mDb.insert(BookmarkTagTable.TABLE_NAME, null, values); + } + } + + Timber.d("import successful!"); + mDb.setTransactionSuccessful(); + } catch (Exception e) { + Timber.e(e, "Failed to import data"); + result = false; + } finally { + mDb.endTransaction(); + } + + return result; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/BookmarksDBHelper.java b/app/src/main/java/com/quran/labs/androidquran/database/BookmarksDBHelper.java new file mode 100644 index 0000000000..a3208e7204 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/BookmarksDBHelper.java @@ -0,0 +1,170 @@ +package com.quran.labs.androidquran.database; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.util.QuranSettings; + +import timber.log.Timber; + +class BookmarksDBHelper extends SQLiteOpenHelper { + + private static final String DB_NAME = "bookmarks.db"; + private static final int DB_VERSION = 3; + + static class BookmarksTable { + static final String TABLE_NAME = "bookmarks"; + static final String ID = "_ID"; + static final String SURA = "sura"; + static final String AYAH = "ayah"; + static final String PAGE = "page"; + static final String ADDED_DATE = "added_date"; + } + + static class TagsTable { + static final String TABLE_NAME = "tags"; + static final String ID = "_ID"; + static final String NAME = "name"; + static final String ADDED_DATE = "added_date"; + } + + static class BookmarkTagTable { + static final String TABLE_NAME = "bookmark_tag"; + static final String ID = "_ID"; + static final String BOOKMARK_ID = "bookmark_id"; + static final String TAG_ID = "tag_id"; + static final String ADDED_DATE = "added_date"; + } + + static class LastPagesTable { + static final String TABLE_NAME = "last_pages"; + static final String ID = "_ID"; + static final String PAGE = "page"; + static final String ADDED_DATE = "added_date"; + } + + static final String QUERY_BOOKMARKS = + "SELECT " + BookmarksTable.TABLE_NAME + "." + BookmarksTable.ID + ", " + + BookmarksTable.TABLE_NAME + "." + BookmarksTable.SURA + ", " + + BookmarksTable.TABLE_NAME + "." + BookmarksTable.AYAH + "," + + BookmarksTable.TABLE_NAME + "." + BookmarksTable.PAGE + ", " + + "strftime('%s', " + BookmarksTable.TABLE_NAME + "." + BookmarksTable.ADDED_DATE + ")" + + ", " + + BookmarkTagTable.TABLE_NAME + "." + BookmarkTagTable.TAG_ID + + " FROM " + + BookmarksTable.TABLE_NAME + " LEFT JOIN " + BookmarkTagTable.TABLE_NAME + + " ON " + BookmarksTable.TABLE_NAME + "." + BookmarksTable.ID + " = " + + BookmarkTagTable.TABLE_NAME + "." + BookmarkTagTable.BOOKMARK_ID; + + private static final String CREATE_BOOKMARKS_TABLE = + " create table if not exists " + BookmarksTable.TABLE_NAME + " (" + + BookmarksTable.ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + BookmarksTable.SURA + " INTEGER, " + + BookmarksTable.AYAH + " INTEGER, " + + BookmarksTable.PAGE + " INTEGER NOT NULL, " + + BookmarksTable.ADDED_DATE + + " TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"; + + private static final String CREATE_TAGS_TABLE = + " create table if not exists " + TagsTable.TABLE_NAME + " (" + + TagsTable.ID + " INTEGER PRIMARY KEY, " + + TagsTable.NAME + " TEXT NOT NULL, " + + TagsTable.ADDED_DATE + " TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"; + + private static final String CREATE_BOOKMARK_TAG_TABLE = + " create table if not exists " + BookmarkTagTable.TABLE_NAME + " (" + + BookmarkTagTable.ID + " INTEGER PRIMARY KEY, " + + BookmarkTagTable.BOOKMARK_ID + " INTEGER NOT NULL, " + + BookmarkTagTable.TAG_ID + " INTEGER NOT NULL, " + + BookmarkTagTable.ADDED_DATE + + " TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"; + + private static final String BOOKMARK_TAGS_INDEX = + "create unique index if not exists " + + BookmarkTagTable.TABLE_NAME + "_index on " + + BookmarkTagTable.TABLE_NAME + "(" + + BookmarkTagTable.BOOKMARK_ID + "," + + BookmarkTagTable.TAG_ID + ");"; + + private static final String LAST_PAGES_TABLE = + "create table if not exists " + LastPagesTable.TABLE_NAME + " (" + + LastPagesTable.ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + LastPagesTable.PAGE + " INTEGER NOT NULL UNIQUE, " + + LastPagesTable.ADDED_DATE + " TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"; + + private static BookmarksDBHelper sInstance; + + public static BookmarksDBHelper getInstance(Context context) { + if (sInstance == null) { + sInstance = new BookmarksDBHelper(context.getApplicationContext()); + } + return sInstance; + } + + private final int lastPage; + + private BookmarksDBHelper(Context context) { + super(context, DB_NAME, null, DB_VERSION); + QuranSettings quranSettings = QuranSettings.getInstance(context); + lastPage = quranSettings.getLastPage(); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_BOOKMARKS_TABLE); + db.execSQL(CREATE_TAGS_TABLE); + db.execSQL(CREATE_BOOKMARK_TAG_TABLE); + db.execSQL(BOOKMARK_TAGS_INDEX); + db.execSQL(LAST_PAGES_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (newVersion <= oldVersion) { + Timber.w("Can't downgrade from version %d to version %d", oldVersion, newVersion); + return; + } + Timber.i("Upgrading database from version %d to version %d", oldVersion, newVersion); + if (oldVersion < 2) { + upgradeToVer2(db); + } + + if (oldVersion < 3) { + upgradeToVer3(db); + } + } + + private void upgradeToVer3(SQLiteDatabase db) { + db.execSQL(LAST_PAGES_TABLE); + if (this.lastPage >= Constants.PAGES_FIRST && this.lastPage <= Constants.PAGES_LAST) { + db.execSQL("INSERT INTO last_pages(page) values(?)", new Object[] { lastPage }); + } + } + + private void upgradeToVer2(SQLiteDatabase db) { + db.execSQL("DROP TABLE IF EXISTS tags"); + db.execSQL("DROP TABLE IF EXISTS ayah_tag_map"); + db.execSQL(CREATE_BOOKMARKS_TABLE); + db.execSQL(CREATE_TAGS_TABLE); + db.execSQL(CREATE_BOOKMARK_TAG_TABLE); + db.execSQL(BOOKMARK_TAGS_INDEX); + copyOldBookmarks(db); + } + + private void copyOldBookmarks(SQLiteDatabase db) { + try { + // Copy over ayah bookmarks + db.execSQL("INSERT INTO bookmarks(_id, sura, ayah, page) " + + "SELECT _id, sura, ayah, page FROM ayah_bookmarks WHERE " + + "bookmarked = 1"); + + // Copy over page bookmarks + db.execSQL("INSERT INTO bookmarks(page) " + + "SELECT _id from page_bookmarks where bookmarked = 1"); + } catch (Exception e) { + Timber.e(e, "Failed to copy old bookmarks"); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.java b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.java new file mode 100644 index 0000000000..c79054fbb5 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.java @@ -0,0 +1,361 @@ +package com.quran.labs.androidquran.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.DefaultDatabaseErrorHandler; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDatabaseCorruptException; +import android.database.sqlite.SQLiteException; +import android.provider.BaseColumns; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QuranText; +import com.quran.labs.androidquran.data.VerseRange; +import com.quran.labs.androidquran.util.QuranFileUtils; + +import java.io.File; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import timber.log.Timber; + + +public class DatabaseHandler { + private static final String COL_SURA = "sura"; + private static final String COL_AYAH = "ayah"; + private static final String COL_TEXT = "text"; + public static final String VERSE_TABLE = "verses"; + public static final String ARABIC_TEXT_TABLE = "arabic_text"; + + private static final String PROPERTIES_TABLE = "properties"; + private static final String COL_PROPERTY = "property"; + private static final String COL_VALUE = "value"; + + private static final String MATCH_END = ""; + private static final String ELLIPSES = "..."; + + private static Map databaseMap = new HashMap<>(); + + private int schemaVersion = 1; + private String matchString; + private SQLiteDatabase database = null; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( { TextType.ARABIC, TextType.TRANSLATION } ) + public @interface TextType { + int ARABIC = 0; + int TRANSLATION = 1; + } + + public static synchronized DatabaseHandler getDatabaseHandler( + Context context, String databaseName) { + DatabaseHandler handler = databaseMap.get(databaseName); + if (handler == null) { + handler = new DatabaseHandler(context.getApplicationContext(), databaseName); + databaseMap.put(databaseName, handler); + } + return handler; + } + + private DatabaseHandler(Context context, String databaseName) throws SQLException { + String base = QuranFileUtils.getQuranDatabaseDirectory(context); + if (base == null) return; + String path = base + File.separator + databaseName; + Crashlytics.log("opening database file: " + path); + try { + database = SQLiteDatabase.openDatabase(path, null, + SQLiteDatabase.NO_LOCALIZED_COLLATORS, new DefaultDatabaseErrorHandler()); + } catch (SQLiteDatabaseCorruptException sce) { + Crashlytics.log("corrupt database: " + databaseName); + throw sce; + } catch (SQLException se){ + Crashlytics.log("database file " + path + + (new File(path).exists()? " exists" : " doesn't exist")); + throw se; + } + + schemaVersion = getSchemaVersion(); + matchString = ""; + } + + public boolean validDatabase() { + return database != null && database.isOpen(); + } + + private Cursor getVerses(int sura, int minAyah, int maxAyah) { + return getVerses(sura, minAyah, maxAyah, VERSE_TABLE); + } + + private int getProperty(@NonNull String column) { + int value = 1; + if (!validDatabase()) { + return value; + } + + Cursor cursor = null; + try { + cursor = database.query(PROPERTIES_TABLE, new String[]{ COL_VALUE }, + COL_PROPERTY + "= ?", new String[]{ column }, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + value = cursor.getInt(0); + } + return value; + } catch (SQLException se) { + return value; + } finally { + DatabaseUtils.closeCursor(cursor); + } + } + + private int getSchemaVersion() { + return getProperty("schema_version"); + } + + public int getTextVersion() { + return getProperty("text_version"); + } + + private Cursor getVerses(int sura, int minAyah, int maxAyah, String table) { + return getVerses(sura, minAyah, sura, maxAyah, table); + } + + /** + * @deprecated use {@link #getVerses(VerseRange, int)} instead + * + * @param minSura start sura + * @param minAyah start ayah + * @param maxSura end sura + * @param maxAyah end ayah + * @param table the table + * @return a Cursor with the data + */ + public Cursor getVerses(int minSura, int minAyah, int maxSura, + int maxAyah, String table) { + return getVersesInternal(new VerseRange(minSura, minAyah, maxSura, maxAyah), table); + } + + public List getVerses(VerseRange verses, @TextType int textType) { + Cursor cursor = null; + List results = new ArrayList<>(); + try { + String table = textType == TextType.ARABIC ? ARABIC_TEXT_TABLE : VERSE_TABLE; + cursor = getVersesInternal(verses, table); + while (cursor != null && cursor.moveToNext()) { + int sura = cursor.getInt(1); + int ayah = cursor.getInt(2); + String text = cursor.getString(3); + results.add(new QuranText(sura, ayah, text)); + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + return results; + } + + private Cursor getVersesInternal(VerseRange verses, String table) { + if (!validDatabase()) { + return null; + } + + StringBuilder whereQuery = new StringBuilder(); + whereQuery.append("("); + + if (verses.startSura == verses.endingSura) { + whereQuery.append(COL_SURA) + .append("=").append(verses.startSura) + .append(" and ").append(COL_AYAH) + .append(">=").append(verses.startAyah) + .append(" and ").append(COL_AYAH) + .append("<=").append(verses.endingAyah); + } else { + // (sura = minSura and ayah >= minAyah) + whereQuery.append("(").append(COL_SURA).append("=") + .append(verses.startSura).append(" and ") + .append(COL_AYAH).append(">=").append(verses.startAyah).append(")"); + + whereQuery.append(" or "); + + // (sura = maxSura and ayah <= maxAyah) + whereQuery.append("(").append(COL_SURA).append("=") + .append(verses.endingSura).append(" and ") + .append(COL_AYAH).append("<=").append(verses.endingAyah).append(")"); + + whereQuery.append(" or "); + + // (sura > minSura and sura < maxSura) + whereQuery.append("(").append(COL_SURA).append(">") + .append(verses.startSura).append(" and ") + .append(COL_SURA).append("<") + .append(verses.endingSura).append(")"); + } + + whereQuery.append(")"); + + return database.query(table, + new String[] { "rowid as _id", COL_SURA, COL_AYAH, COL_TEXT }, + whereQuery.toString(), null, null, null, + COL_SURA + "," + COL_AYAH); + } + + /** + * @deprecated use {@link #getVerses(VerseRange, int)} instead + * @param sura the sura + * @param ayah the ayah + * @return the result + */ + public Cursor getVerse(int sura, int ayah) { + return getVerses(sura, ayah, ayah); + } + + public Cursor getVersesByIds(List ids) { + StringBuilder builder = new StringBuilder(); + for (int i = 0, idsSize = ids.size(); i < idsSize; i++) { + if (i > 0) { + builder.append(","); + } + builder.append(ids.get(i)); + } + + Timber.d("querying verses by ids for tags..."); + final String sql = "SELECT rowid as _id, " + COL_SURA + ", " + COL_AYAH + ", " + COL_TEXT + + " FROM " + ARABIC_TEXT_TABLE + " WHERE rowid in(" + builder.toString() + ")"; + return database.rawQuery(sql, null); + } + + public Cursor search(String query, boolean withSnippets) { + return search(query, VERSE_TABLE, withSnippets); + } + public Cursor searchAdvanced(String[] query) { + return searchAdvanced(query, VERSE_TABLE); + } + public Cursor searchAdvanced(String [] q, String table) { + if (!validDatabase()) { + return null; + } + + String query = q[0]; + String operator = " like "; + String whatTextToSelect = COL_TEXT; + + boolean useFullTextIndex = (schemaVersion > 1); +// if (useFullTextIndex) { +// operator = " MATCH "; +// query = query + "*"; +// } else { + query = COL_TEXT +" Like '%" + query + "%' "; +// } + if(!q[1].equals("")) + query+= " AND "+ COL_TEXT+" LIKE '%"+q[1]+"%' "; + + if(!q[2].equals("")) + query+= " OR "+COL_TEXT+" LIKE '%"+q[2]+"%' "; + + if(!q[3].equals("")) + query+= " AND "+COL_TEXT+" NOT LIKE '%"+q[3]+"%' "; + + int pos = 0; + int found = 0; + boolean done = false; + while (!done) { + int quote = query.indexOf("\"", pos); + if (quote > -1) { + found++; + pos = quote + 1; + } else { + done = true; + } + } + + if (found % 2 != 0) { + query = query.replaceAll("\"", ""); + } +// +// if (useFullTextIndex && true) { +// whatTextToSelect = "snippet(" + table + ", '" + +// matchString + "', '" + MATCH_END + +// "', '" + ELLIPSES + "', -1, 64)"; +// } + + String qtext = "select rowid as " + BaseColumns._ID + ", " + COL_SURA + ", " + COL_AYAH +" ,"+COL_TEXT+ + " from " + table + " where " + query ; //limit 150 + Crashlytics.log("search query: " + qtext + ", query: " + query); + + try { + return database.rawQuery(qtext, null); + +// return database.rawQuery(qtext, new String[]{ query }); + } catch (SQLiteException sq){ + Crashlytics.logException(sq); + return null; + + } + catch (Exception e){ + Crashlytics.logException(e); + return null; + } + } + public Cursor search(String q, String table, boolean withSnippets) { + if (!validDatabase()) { + return null; + } + + String query = q; + String operator = " like "; + String whatTextToSelect = COL_TEXT; + + boolean useFullTextIndex = (schemaVersion > 1); + if (useFullTextIndex) { + operator = " MATCH "; + query = query + "*"; + } else { + query = "%" + query + "%"; + } + + int pos = 0; + int found = 0; + boolean done = false; + while (!done) { + int quote = query.indexOf("\"", pos); + if (quote > -1) { + found++; + pos = quote + 1; + } else { + done = true; + } + } + + if (found % 2 != 0) { + query = query.replaceAll("\"", ""); + } + + if (useFullTextIndex && withSnippets) { + whatTextToSelect = "snippet(" + table + ", '" + + matchString + "', '" + MATCH_END + + "', '" + ELLIPSES + "', -1, 64)"; + } + + String qtext = "select rowid as " + BaseColumns._ID + ", " + COL_SURA + ", " + COL_AYAH + + ", " + whatTextToSelect + " from " + table + " where " + COL_TEXT + + operator + " ? " + " "; //limit 150 + Crashlytics.log("search query: " + qtext + ", query: " + query); + + try { + + return database.rawQuery(qtext, new String[]{ query }); + } catch (Exception e){ + Crashlytics.logException(e); + return null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/DatabaseUtils.java b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseUtils.java new file mode 100644 index 0000000000..c70629d1db --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseUtils.java @@ -0,0 +1,16 @@ +package com.quran.labs.androidquran.database; + +import android.database.Cursor; + +public class DatabaseUtils { + + public static void closeCursor(Cursor cursor) { + if (cursor != null) { + try { + cursor.close(); + } catch (Exception e) { + // no op + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/SuraTimingDatabaseHandler.java b/app/src/main/java/com/quran/labs/androidquran/database/SuraTimingDatabaseHandler.java new file mode 100644 index 0000000000..89defd7d1e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/SuraTimingDatabaseHandler.java @@ -0,0 +1,68 @@ +package com.quran.labs.androidquran.database; + +import com.crashlytics.android.Crashlytics; + +import android.database.Cursor; +import android.database.DefaultDatabaseErrorHandler; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDatabaseCorruptException; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +public class SuraTimingDatabaseHandler { + private SQLiteDatabase mDatabase = null; + + public static class TimingsTable { + public static final String TABLE_NAME = "timings"; + public static final String COL_SURA = "sura"; + public static final String COL_AYAH = "ayah"; + public static final String COL_TIME = "time"; + } + + private static Map sSuraDatabaseMap = new HashMap<>(); + + public synchronized static SuraTimingDatabaseHandler getDatabaseHandler(String path) { + SuraTimingDatabaseHandler handler = sSuraDatabaseMap.get(path); + if (handler == null) { + handler = new SuraTimingDatabaseHandler(path); + sSuraDatabaseMap.put(path, handler); + } + return handler; + } + + private SuraTimingDatabaseHandler(String path) throws SQLException { + Crashlytics.log("opening gapless data file, " + path); + try { + mDatabase = SQLiteDatabase.openDatabase(path, null, + SQLiteDatabase.NO_LOCALIZED_COLLATORS, new DefaultDatabaseErrorHandler()); + } catch (SQLiteDatabaseCorruptException sce) { + Crashlytics.log("database corrupted: " + path); + mDatabase = null; + } catch (SQLException se) { + Crashlytics.log("database at " + path + + (new File(path).exists() ? " exists" : " doesn't exist")); + Crashlytics.logException(se); + mDatabase = null; + } + } + + private boolean validDatabase() { + return mDatabase != null && mDatabase.isOpen(); + } + + public Cursor getAyahTimings(int sura) { + if (!validDatabase()) { return null; } + try { + return mDatabase.query(TimingsTable.TABLE_NAME, + new String[]{ TimingsTable.COL_SURA, + TimingsTable.COL_AYAH, TimingsTable.COL_TIME }, + TimingsTable.COL_SURA + "=" + sura, + null, null, null, TimingsTable.COL_AYAH + " ASC"); + } catch (Exception e) { + return null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBAdapter.java b/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBAdapter.java new file mode 100644 index 0000000000..b367f40bfd --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBAdapter.java @@ -0,0 +1,122 @@ +package com.quran.labs.androidquran.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.NonNull; +import android.util.SparseArray; + +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.dao.translation.TranslationItem; +import com.quran.labs.androidquran.util.QuranFileUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import timber.log.Timber; + +import static com.quran.labs.androidquran.database.TranslationsDBHelper.TranslationsTable; + +@Singleton +public class TranslationsDBAdapter { + + private final Context context; + private final SQLiteDatabase db; + private volatile List cachedTranslations; + + @Inject + public TranslationsDBAdapter(Context context, TranslationsDBHelper adapter) { + this.context = context; + this.db = adapter.getWritableDatabase(); + } + + public SparseArray getTranslationsHash() { + List items = getTranslations(); + + SparseArray result = new SparseArray<>(); + for (int i = 0, itemsSize = items.size(); i < itemsSize; i++) { + LocalTranslation item = items.get(i); + result.put(item.id, item); + } + return result; + } + + @NonNull + public List getTranslations() { + // intentional, since cachedTranslations can be replaced by another thread, causing the check + // to be true, but the cached object returned to be null (or to change). + List cached = cachedTranslations; + if (cached != null && cached.size() > 0) { + return cached; + } + + List items = new ArrayList<>(); + Cursor cursor = db.query(TranslationsTable.TABLE_NAME, + null, null, null, null, null, + TranslationsTable.ID + " ASC"); + if (cursor != null) { + while (cursor.moveToNext()) { + int id = cursor.getInt(0); + String name = cursor.getString(1); + String translator = cursor.getString(2); + String translatorForeign = cursor.getString(3); + String filename = cursor.getString(4); + String url = cursor.getString(5); + int version = cursor.getInt(6); + + if (QuranFileUtils.hasTranslation(context, filename)) { + items.add(new LocalTranslation(id, filename, name, translator, + translatorForeign, url, version)); + } + } + cursor.close(); + } + items = Collections.unmodifiableList(items); + if (items.size() > 0) { + cachedTranslations = items; + } + return items; + } + + public boolean writeTranslationUpdates(List updates) { + boolean result = true; + db.beginTransaction(); + try { + for (int i = 0, updatesSize = updates.size(); i < updatesSize; i++) { + TranslationItem item = updates.get(i); + if (item.exists()) { + ContentValues values = new ContentValues(); + values.put(TranslationsTable.ID, item.translation.id); + values.put(TranslationsTable.NAME, item.translation.displayName); + values.put(TranslationsTable.TRANSLATOR, item.translation.translator); + values.put(TranslationsTable.TRANSLATOR_FOREIGN, + item.translation.translatorNameLocalized); + values.put(TranslationsTable.FILENAME, item.translation.filename); + values.put(TranslationsTable.URL, item.translation.fileUrl); + values.put(TranslationsTable.VERSION, item.localVersion); + + db.replace(TranslationsTable.TABLE_NAME, null, values); + } else { + db.delete(TranslationsTable.TABLE_NAME, + TranslationsTable.ID + " = " + item.translation.id, null); + } + } + db.setTransactionSuccessful(); + + // clear the cached translations + this.cachedTranslations = null; + } catch (Exception e) { + result = false; + Timber.d(e, "error writing translation updates"); + } finally { + db.endTransaction(); + } + + return result; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBHelper.java b/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBHelper.java new file mode 100644 index 0000000000..95e7523c2f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBHelper.java @@ -0,0 +1,76 @@ +package com.quran.labs.androidquran.database; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +class TranslationsDBHelper extends SQLiteOpenHelper { + + private static final String DB_NAME = "translations.db"; + private static final int DB_VERSION = 2; + private static final String CREATE_TRANSLATIONS_TABLE = + "CREATE TABLE " + TranslationsTable.TABLE_NAME + "(" + + TranslationsTable.ID + " integer primary key, " + + TranslationsTable.NAME + " varchar not null, " + + TranslationsTable.TRANSLATOR + " varchar, " + + TranslationsTable.TRANSLATOR_FOREIGN + " varchar, " + + TranslationsTable.FILENAME + " varchar not null, " + + TranslationsTable.URL + " varchar, " + + TranslationsTable.VERSION + " integer not null default 0);"; + + @Inject + TranslationsDBHelper(Context context) { + super(context, DB_NAME, null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_TRANSLATIONS_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion < 2) { + // a new column is added and columns are re-arranged + final String BACKUP_TABLE = TranslationsTable.TABLE_NAME + "_backup"; + db.beginTransaction(); + try { + db.execSQL("ALTER TABLE " + TranslationsTable.TABLE_NAME + " RENAME TO " + BACKUP_TABLE); + db.execSQL(CREATE_TRANSLATIONS_TABLE); + db.execSQL("INSERT INTO " + TranslationsTable.TABLE_NAME + " (" + + TranslationsTable.ID + ", " + + TranslationsTable.NAME + ", " + + TranslationsTable.TRANSLATOR + ", " + + TranslationsTable.FILENAME + ", " + + TranslationsTable.URL + ", " + + TranslationsTable.VERSION + ")" + + "SELECT " + TranslationsTable.ID + ", " + + TranslationsTable.NAME + ", " + + TranslationsTable.TRANSLATOR + ", " + + TranslationsTable.FILENAME + ", " + + TranslationsTable.URL + ", " + + TranslationsTable.VERSION + + " FROM " + BACKUP_TABLE); + db.execSQL("DROP TABLE " + BACKUP_TABLE); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + } + + static class TranslationsTable { + static final String TABLE_NAME = "translations"; + static final String ID = "id"; + static final String NAME = "name"; + static final String TRANSLATOR = "translator"; + static final String TRANSLATOR_FOREIGN = "translator_foreign"; + static final String FILENAME = "filename"; + static final String URL = "url"; + static final String VERSION = "version"; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/di/ActivityScope.java b/app/src/main/java/com/quran/labs/androidquran/di/ActivityScope.java new file mode 100644 index 0000000000..01dac8a967 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/di/ActivityScope.java @@ -0,0 +1,7 @@ +package com.quran.labs.androidquran.di; + +import javax.inject.Scope; + +@Scope +public @interface ActivityScope { +} diff --git a/app/src/main/java/com/quran/labs/androidquran/di/QuranPageScope.java b/app/src/main/java/com/quran/labs/androidquran/di/QuranPageScope.java new file mode 100644 index 0000000000..430c8b6ec2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/di/QuranPageScope.java @@ -0,0 +1,7 @@ +package com.quran.labs.androidquran.di; + +import javax.inject.Scope; + +@Scope +public @interface QuranPageScope { +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkImportExportModel.java b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkImportExportModel.java new file mode 100644 index 0000000000..c1a956cc32 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkImportExportModel.java @@ -0,0 +1,68 @@ +package com.quran.labs.androidquran.model.bookmark; + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.v4.content.FileProvider; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.BookmarkData; +import com.quran.labs.androidquran.database.BookmarksDBAdapter; + +import java.io.File; +import java.io.IOException; + +import javax.inject.Inject; + +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.Okio; + + +public class BookmarkImportExportModel { + private static final String FILE_NAME = "quran_android.backup"; + + private final Context appContext; + private final BookmarkJsonModel jsonModel; + private final BookmarkModel bookmarkModel; + + @Inject + BookmarkImportExportModel(Context appContext, + BookmarkJsonModel model, BookmarkModel bookmarkModel) { + this.appContext = appContext; + this.jsonModel = model; + this.bookmarkModel = bookmarkModel; + } + + public Single readBookmarks(@NonNull final BufferedSource source) { + return Single.defer(() -> Single.just(jsonModel.fromJson(source))) + .subscribeOn(Schedulers.io()); + } + + public Single exportBookmarksObservable() { + return bookmarkModel.getBookmarkDataObservable(BookmarksDBAdapter.SORT_DATE_ADDED) + .flatMap(bookmarkData -> Single.just(exportBookmarks(bookmarkData))) + .subscribeOn(Schedulers.io()); + } + + @NonNull + private Uri exportBookmarks(BookmarkData data) throws IOException { + // exporting often fails due to the thread being interrupted (when okio sees an interrupted + // thread, it doesn't flush the buffer and instead just throws an exception and returns). + // may revisit this after hearing back about https://github.com/ReactiveX/RxJava/issues/5024. + Thread.interrupted(); + File externalFilesDir = new File(appContext.getExternalFilesDir(null), "backups"); + if (externalFilesDir.exists() || externalFilesDir.mkdir()) { + File file = new File(externalFilesDir, FILE_NAME); + BufferedSink sink = Okio.buffer(Okio.sink(file)); + jsonModel.toJson(sink, data); + sink.close(); + + return FileProvider.getUriForFile( + appContext, appContext.getString(R.string.file_authority), file); + } + throw new IOException("Unable to write to external files directory."); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkJsonModel.java b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkJsonModel.java new file mode 100644 index 0000000000..8c568ebfe5 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkJsonModel.java @@ -0,0 +1,33 @@ +package com.quran.labs.androidquran.model.bookmark; + +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.dao.BookmarkData; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +import java.io.IOException; + +import javax.inject.Inject; + +import okio.BufferedSink; +import okio.BufferedSource; + +class BookmarkJsonModel { + private final JsonAdapter jsonAdapter; + + @Inject + BookmarkJsonModel() { + Moshi moshi = new Moshi.Builder().build(); + jsonAdapter = moshi.adapter(BookmarkData.class); + } + + void toJson(BufferedSink sink, BookmarkData bookmarks) throws IOException { + jsonAdapter.toJson(sink, bookmarks); + } + + @NonNull + BookmarkData fromJson(BufferedSource jsonSource) throws IOException { + return jsonAdapter.fromJson(jsonSource); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkModel.java b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkModel.java new file mode 100644 index 0000000000..d074b4cdbc --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkModel.java @@ -0,0 +1,189 @@ +package com.quran.labs.androidquran.model.bookmark; + +import android.support.v4.util.Pair; + +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.dao.BookmarkData; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.database.BookmarksDBAdapter; +import com.quran.labs.androidquran.ui.helpers.QuranRow; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Completable; +import io.reactivex.Maybe; +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.PublishSubject; +import io.reactivex.subjects.Subject; + + +@Singleton +public class BookmarkModel { + private final RecentPageModel recentPageModel; + private final BookmarksDBAdapter bookmarksDBAdapter; + private final Subject tagPublishSubject; + private final Subject bookmarksPublishSubject; + + @Inject + public BookmarkModel(BookmarksDBAdapter bookmarksAdapter, RecentPageModel recentPageModel) { + this.recentPageModel = recentPageModel; + this.bookmarksDBAdapter = bookmarksAdapter; + + tagPublishSubject = PublishSubject.create().toSerialized(); + bookmarksPublishSubject = PublishSubject.create().toSerialized(); + } + + public Observable tagsObservable() { + return tagPublishSubject.hide(); + } + + public Observable recentPagesUpdatedObservable() { + return recentPageModel.getRecentPagesUpdatedObservable(); + } + + public Observable bookmarksObservable() { + return bookmarksPublishSubject.hide(); + } + + public Single getBookmarkDataObservable(final int sortOrder) { + return Single.zip(getTagsObservable(), + getBookmarksObservable(sortOrder), + recentPageModel.getRecentPagesObservable(), + BookmarkData::new) + .subscribeOn(Schedulers.io()); + } + + public Completable removeItemsObservable( + final List itemsToRemoveRef) { + return Completable.fromCallable(() -> { + List tagsToDelete = new ArrayList<>(); + List bookmarksToDelete = new ArrayList<>(); + List> untag = new ArrayList<>(); + for (int i = 0, size = itemsToRemoveRef.size(); i < size; i++) { + QuranRow row = itemsToRemoveRef.get(i); + if (row.isBookmarkHeader() && row.tagId > 0) { + tagsToDelete.add(row.tagId); + } else if (row.isBookmark() && row.bookmarkId > 0) { + if (row.tagId > 0) { + untag.add(new Pair<>(row.bookmarkId, row.tagId)); + } else { + bookmarksToDelete.add(row.bookmarkId); + } + } + } + bookmarksDBAdapter.bulkDelete(tagsToDelete, bookmarksToDelete, untag); + return null; + }).subscribeOn(Schedulers.io()); + } + + public Observable addTagObservable(final String title) { + return Observable.fromCallable(() -> { + Long result = bookmarksDBAdapter.addTag(title); + tagPublishSubject.onNext(new Tag(result, title)); + return result; + }).subscribeOn(Schedulers.io()); + } + + public Completable updateTag(final Tag tag) { + return Completable.fromCallable(() -> { + boolean result = bookmarksDBAdapter.updateTag(tag.id, tag.name); + if (result) { + tagPublishSubject.onNext(tag); + } + return null; + }).subscribeOn(Schedulers.io()); + } + + public Observable updateBookmarkTags(final long[] bookmarkIds, + final Set tagIds, + final boolean deleteNonTagged) { + return Observable.fromCallable(() -> { + boolean result = bookmarksDBAdapter.tagBookmarks(bookmarkIds, tagIds, deleteNonTagged); + if (result) { + bookmarksPublishSubject.onNext(true); + } + return result; + }).subscribeOn(Schedulers.io()); + } + + public Observable safeAddBookmark(final Integer sura, final Integer ayah, final int page) { + return Observable.fromCallable(() -> { + long result = bookmarksDBAdapter.addBookmarkIfNotExists(sura, ayah, page); + bookmarksPublishSubject.onNext(true); + return result; + }).subscribeOn(Schedulers.io()); + } + + public Single> getTagsObservable() { + return Single.fromCallable(bookmarksDBAdapter::getTags).subscribeOn(Schedulers.io()); + } + + private Single> getBookmarksObservable(final int sortOrder) { + return Single.fromCallable(() -> bookmarksDBAdapter.getBookmarks(sortOrder)); + } + + public Maybe> getBookmarkTagIds(Single bookmarkIdSingle) { + return bookmarkIdSingle.filter(bookmarkId -> bookmarkId > 0) + .map(bookmarksDBAdapter::getBookmarkTagIds) + .subscribeOn(Schedulers.io()); + } + + public Single getBookmarkId(final Integer sura, final Integer ayah, final int page) { + return Single.fromCallable(() -> bookmarksDBAdapter.getBookmarkId(sura, ayah, page)) + .subscribeOn(Schedulers.io()); + } + + public Observable> getBookmarkedAyahsOnPageObservable(Integer... pages) { + return Observable.fromArray(pages) + .map(bookmarksDBAdapter::getBookmarkedAyahsOnPage) + .filter(bookmarks -> !bookmarks.isEmpty()) + .subscribeOn(Schedulers.io()); + } + + public Observable> getIsBookmarkedObservable(Integer... pages) { + return Observable.fromArray(pages) + .map(page -> new Pair<>(page, bookmarksDBAdapter.getBookmarkId(null, null, page) > 0)) + .subscribeOn(Schedulers.io()); + } + + public Single getIsBookmarkedObservable( + final Integer sura, final Integer ayah, final int page) { + return getBookmarkId(sura, ayah, page) + .map(bookmarkId -> bookmarkId > 0) + .subscribeOn(Schedulers.io()); + } + + public Single toggleBookmarkObservable( + final Integer sura, final Integer ayah, final int page) { + return getBookmarkId(sura, ayah, page) + .map(bookmarkId -> { + boolean result; + if (bookmarkId > 0) { + bookmarksDBAdapter.removeBookmark(bookmarkId); + result = false; + } else { + bookmarksDBAdapter.addBookmark(sura, ayah, page); + result = true; + } + bookmarksPublishSubject.onNext(true); + return result; + }).subscribeOn(Schedulers.io()); + } + + public Observable importBookmarksObservable(final BookmarkData data) { + return Observable.fromCallable(() -> { + boolean result = bookmarksDBAdapter.importBookmarks(data); + if (result) { + bookmarksPublishSubject.onNext(true); + } + return result; + }).subscribeOn(Schedulers.io()).cache(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkResult.java b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkResult.java new file mode 100644 index 0000000000..03c6717af2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/BookmarkResult.java @@ -0,0 +1,18 @@ +package com.quran.labs.androidquran.model.bookmark; + +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.ui.helpers.QuranRow; + +import java.util.List; +import java.util.Map; + +public class BookmarkResult { + + public final List rows; + public final Map tagMap; + + public BookmarkResult(List rows, Map tagMap) { + this.rows = rows; + this.tagMap = tagMap; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/bookmark/RecentPageModel.java b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/RecentPageModel.java new file mode 100644 index 0000000000..80231ead25 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/bookmark/RecentPageModel.java @@ -0,0 +1,132 @@ +package com.quran.labs.androidquran.model.bookmark; + +import android.support.annotation.UiThread; + +import com.quran.labs.androidquran.dao.RecentPage; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.database.BookmarksDBAdapter; + +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.observers.DisposableSingleObserver; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.BehaviorSubject; +import io.reactivex.subjects.PublishSubject; +import io.reactivex.subjects.Subject; + +@Singleton +public class RecentPageModel { + + private final BookmarksDBAdapter bookmarksDBAdapter; + private final Subject lastPageSubject; + + private DisposableSingleObserver> initialDataSubscription; + private final Observable recentPagesUpdatedObservable; + private final Subject recentWriterSubject; + + @Inject + public RecentPageModel(BookmarksDBAdapter adapter) { + this.bookmarksDBAdapter = adapter; + this.lastPageSubject = BehaviorSubject.create(); + this.recentWriterSubject = PublishSubject.create(); + + recentPagesUpdatedObservable = this.recentWriterSubject.hide() + .observeOn(Schedulers.io()) + .map(update -> { + if (update.deleteRangeStart != null) { + bookmarksDBAdapter.replaceRecentRangeWithPage( + update.deleteRangeStart, update.deleteRangeEnd, update.page); + } else { + bookmarksDBAdapter.addRecentPage(update.page); + } + return true; + }).share(); + + // there needs to always be one subscriber in order for us to properly be able + // to write updates to the database (even when no one else is subscribed). + recentPagesUpdatedObservable.subscribe(); + + // fetch the first set of "recent pages" + initialDataSubscription = getRecentPagesObservable() + // this is only to avoid serializing lastPageSubject + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver>() { + @Override + public void onSuccess(List recentPages) { + int page = recentPages.size() > 0 ? recentPages.get(0).page : Constants.NO_PAGE; + lastPageSubject.onNext(page); + initialDataSubscription = null; + } + + @Override + public void onError(Throwable e) { + } + }); + } + + @UiThread + public void updateLatestPage(int page) { + if (initialDataSubscription != null) { + // it is possible (though unlikely) for a page update to come in while we're still waiting + // for the initial query of recent pages from the database. if so, unsubscribe from the initial + // query since its data will be stale relative to this data. + initialDataSubscription.dispose(); + } + lastPageSubject.onNext(page); + } + + @UiThread + public void persistLatestPage(int minimumPage, int maximumPage, int lastPage) { + Integer min = minimumPage == maximumPage ? null : minimumPage; + Integer max = min == null ? null : maximumPage; + recentWriterSubject.onNext(new PersistRecentPagesRequest(lastPage, min, max)); + } + + /** + * Returns an observable of the very latest pages visited + * + * Note that this stream never terminates, and will return pages even when they have not yet been + * persisted to the database. This is basically a stream of every page as it gets visited. + * + * @return an Observable of the latest pages visited + */ + public Observable getLatestPageObservable() { + return lastPageSubject.hide(); + } + + /** + * Returns an observable of observing when recent pages change in the database + * + * Note that this stream never terminates, and will emit onNext when the database has been + * updated. Note that writes to the database are typically batched, and as thus, this observable + * will fire a lot less than {@link #getLatestPageObservable()}. + * + * @return an observable that receives events whenever recent pages is persisted + */ + Observable getRecentPagesUpdatedObservable() { + return recentPagesUpdatedObservable; + } + + Single> getRecentPagesObservable() { + return Single.fromCallable(bookmarksDBAdapter::getRecentPages) + .subscribeOn(Schedulers.io()); + } + + private static class PersistRecentPagesRequest { + final int page; + final Integer deleteRangeStart; + final Integer deleteRangeEnd; + + PersistRecentPagesRequest(int page, Integer deleteRangeStart, Integer deleteRangeEnd) { + this.page = page; + this.deleteRangeStart = deleteRangeStart; + this.deleteRangeEnd = deleteRangeEnd; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/quran/CoordinatesModel.java b/app/src/main/java/com/quran/labs/androidquran/model/quran/CoordinatesModel.java new file mode 100644 index 0000000000..45ca251a69 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/quran/CoordinatesModel.java @@ -0,0 +1,51 @@ +package com.quran.labs.androidquran.model.quran; + +import android.graphics.RectF; +import android.support.v4.util.Pair; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.data.AyahInfoDatabaseHandler; +import com.quran.labs.androidquran.data.AyahInfoDatabaseProvider; +import com.quran.labs.androidquran.di.ActivityScope; + +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +import javax.inject.Inject; + +import io.reactivex.Observable; +import io.reactivex.schedulers.Schedulers; + +@ActivityScope +public class CoordinatesModel { + private final AyahInfoDatabaseProvider ayahInfoDatabaseProvider; + + @Inject + CoordinatesModel(AyahInfoDatabaseProvider ayahInfoDatabaseProvider) { + this.ayahInfoDatabaseProvider = ayahInfoDatabaseProvider; + } + + public Observable> getPageCoordinates(Integer... pages) { + AyahInfoDatabaseHandler database = ayahInfoDatabaseProvider.getAyahInfoHandler(); + if (database == null) { + return Observable.error(new NoSuchElementException("No AyahInfoDatabaseHandler found!")); + } + + return Observable.fromArray(pages) + .map(page -> new Pair<>(page, database.getPageBounds(page))) + .subscribeOn(Schedulers.computation()); + } + + public Observable>>> getAyahCoordinates( + Integer... pages) { + AyahInfoDatabaseHandler database = ayahInfoDatabaseProvider.getAyahInfoHandler(); + if (database == null) { + return Observable.error(new NoSuchElementException("No AyahInfoDatabaseHandler found!")); + } + + return Observable.fromArray(pages) + .map(page -> new Pair<>(page, database.getVersesBoundsForPage(page))) + .subscribeOn(Schedulers.computation()); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java b/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java new file mode 100644 index 0000000000..080e829c93 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java @@ -0,0 +1,160 @@ +package com.quran.labs.androidquran.model.translation; + +import android.content.Context; +import android.database.Cursor; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; + +import com.quran.labs.androidquran.common.QuranText; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.data.QuranDataProvider; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.database.DatabaseHandler; +import com.quran.labs.androidquran.database.DatabaseUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +@Singleton +public class ArabicDatabaseUtils { + public static final String AR_BASMALLAH = "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ"; + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) static final int NUMBER_OF_WORDS = 4; + + private final Context appContext; + private DatabaseHandler arabicDatabaseHandler; + + @Inject + ArabicDatabaseUtils(Context context) { + this.appContext = context; + arabicDatabaseHandler = getArabicDatabaseHandler(); + } + + DatabaseHandler getArabicDatabaseHandler() { + if (arabicDatabaseHandler == null) { + try { + arabicDatabaseHandler = DatabaseHandler.getDatabaseHandler( + appContext.getApplicationContext(), QuranDataProvider.QURAN_ARABIC_DATABASE); + } catch (Exception e) { + // ignore + } + } + return arabicDatabaseHandler; + } + + @NonNull + public Single> getVerses(final SuraAyah start, final SuraAyah end) { + return Single.fromCallable(() -> { + List verses = new ArrayList<>(); + + Cursor cursor = null; + try { + DatabaseHandler arabicDatabaseHandler = getArabicDatabaseHandler(); + cursor = arabicDatabaseHandler.getVerses(start.sura, start.ayah, + end.sura, end.ayah, DatabaseHandler.ARABIC_TEXT_TABLE); + while (cursor.moveToNext()) { + QuranText verse = new QuranText(cursor.getInt(1), cursor.getInt(2), cursor.getString(3)); + verses.add(verse); + } + } catch (Exception e) { + // no op + } finally { + DatabaseUtils.closeCursor(cursor); + } + return verses; + }).subscribeOn(Schedulers.io()); + } + + public List hydrateAyahText(List bookmarks) { + List ayahIds = new ArrayList<>(); + for (int i = 0, bookmarksSize = bookmarks.size(); i < bookmarksSize; i++) { + Bookmark bookmark = bookmarks.get(i); + if (!bookmark.isPageBookmark()) { + ayahIds.add(QuranInfo.getAyahId(bookmark.sura, bookmark.ayah)); + } + } + + return ayahIds.isEmpty() ? bookmarks : + mergeBookmarksWithAyahText(bookmarks, getAyahTextForAyat(ayahIds)); + } + + Map getAyahTextForAyat(List ayat) { + Map result = new HashMap<>(ayat.size()); + DatabaseHandler arabicDatabaseHandler = getArabicDatabaseHandler(); + if (arabicDatabaseHandler != null) { + Cursor cursor = null; + try { + cursor = arabicDatabaseHandler.getVersesByIds(ayat); + while (cursor.moveToNext()) { + int id = cursor.getInt(0); + int sura = cursor.getInt(1); + int ayah = cursor.getInt(2); + String text = cursor.getString(3); + result.put(id, getFirstFewWordsFromAyah(sura, ayah, text)); + } + } finally { + DatabaseUtils.closeCursor(cursor); + } + } + return result; + } + + private List mergeBookmarksWithAyahText( + List bookmarks, Map ayahMap) { + List result; + if (ayahMap.isEmpty()) { + result = bookmarks; + } else { + result = new ArrayList<>(bookmarks.size()); + for (int i = 0, bookmarksSize = bookmarks.size(); i < bookmarksSize; i++) { + Bookmark bookmark = bookmarks.get(i); + Bookmark toAdd; + if (bookmark.isPageBookmark()) { + toAdd = bookmark; + } else { + String ayahText = ayahMap.get(QuranInfo.getAyahId(bookmark.sura, bookmark.ayah)); + toAdd = ayahText == null ? bookmark : bookmark.withAyahText(ayahText); + } + result.add(toAdd); + } + } + return result; + } + + static String getFirstFewWordsFromAyah(int sura, int ayah, String text) { + String ayahText = getAyahWithoutBasmallah(sura, ayah, text); + int start = 0; + for (int i = 0; i < NUMBER_OF_WORDS && start > -1; i++) { + start = ayahText.indexOf(' ', start + 1); + } + return start > 0 ? ayahText.substring(0, start) : ayahText; + } + + /** + * Get the actual ayahText from the given ayahText. This is important because, currently, the + * arabic database (quran.ar.db) has the first verse from each sura as "[basmallah] ayah" - this + * method just returns ayah without basmallah. + * + * @param sura the sura number + * @param ayah the ayah number + * @param ayahText the ayah text + * @return the ayah without the basmallah + */ + public static String getAyahWithoutBasmallah(int sura, int ayah, String ayahText) { + // note that ayahText.startsWith check is always true for now - but it's explicitly here so + // that if we update quran.ar.db one day to fix this issue and older clients get a new copy of + // the database, their code continues to work as before. + if (ayah == 1 && sura != 9 && sura != 1 && ayahText.startsWith(AR_BASMALLAH)) { + return ayahText.substring(AR_BASMALLAH.length() + 1); + } + return ayahText; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/model/translation/TranslationModel.java b/app/src/main/java/com/quran/labs/androidquran/model/translation/TranslationModel.java new file mode 100644 index 0000000000..68d9d11ae6 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/model/translation/TranslationModel.java @@ -0,0 +1,43 @@ +package com.quran.labs.androidquran.model.translation; + +import android.content.Context; + +import com.quran.labs.androidquran.common.QuranText; +import com.quran.labs.androidquran.data.QuranDataProvider; +import com.quran.labs.androidquran.data.VerseRange; +import com.quran.labs.androidquran.database.DatabaseHandler; +import com.quran.labs.androidquran.di.ActivityScope; + +import java.util.List; + +import javax.inject.Inject; + +import io.reactivex.Single; + +@ActivityScope +public class TranslationModel { + private Context appContext; + + @Inject + TranslationModel(Context appContext) { + this.appContext = appContext; + } + + public Single> getArabicFromDatabase(VerseRange verses) { + return getVersesFromDatabase(verses, + QuranDataProvider.QURAN_ARABIC_DATABASE, DatabaseHandler.TextType.ARABIC); + } + + public Single> getTranslationFromDatabase(VerseRange verses, String db) { + return getVersesFromDatabase(verses, db, DatabaseHandler.TextType.TRANSLATION); + } + + private Single> getVersesFromDatabase(VerseRange verses, + String database, + @DatabaseHandler.TextType int type) { + return Single.fromCallable(() -> { + DatabaseHandler databaseHandler = DatabaseHandler.getDatabaseHandler(appContext, database); + return databaseHandler.getVerses(verses, type); + }); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/module/activity/PagerActivityModule.java b/app/src/main/java/com/quran/labs/androidquran/module/activity/PagerActivityModule.java new file mode 100644 index 0000000000..51f610adef --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/module/activity/PagerActivityModule.java @@ -0,0 +1,31 @@ +package com.quran.labs.androidquran.module.activity; + +import com.quran.labs.androidquran.di.ActivityScope; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranUtils; + +import dagger.Module; +import dagger.Provides; + +@Module +public class PagerActivityModule { + private final PagerActivity pagerActivity; + + public PagerActivityModule(PagerActivity pagerActivity) { + this.pagerActivity = pagerActivity; + } + + @Provides + AyahSelectedListener provideAyahSelectedListener() { + return this.pagerActivity; + } + + @Provides + @ActivityScope + String provideImageWidth(QuranScreenInfo screenInfo) { + return QuranUtils.isDualPages(pagerActivity, screenInfo) ? + screenInfo.getTabletWidthParam() : screenInfo.getWidthParam(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/module/application/ApplicationModule.java b/app/src/main/java/com/quran/labs/androidquran/module/application/ApplicationModule.java new file mode 100644 index 0000000000..e11494f83e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/module/application/ApplicationModule.java @@ -0,0 +1,38 @@ +package com.quran.labs.androidquran.module.application; + +import android.app.Application; +import android.content.Context; + +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranSettings; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class ApplicationModule { + private final Application application; + + public ApplicationModule(Application application) { + this.application = application; + } + + @Provides + Context provideApplicationContext() { + return this.application; + } + + @Provides + @Singleton + QuranSettings provideQuranSettings() { + return QuranSettings.getInstance(application); + } + + @Provides + @Singleton + QuranScreenInfo provideQuranScreenInfo() { + return QuranScreenInfo.getOrMakeInstance(application); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/module/application/DatabaseModule.java b/app/src/main/java/com/quran/labs/androidquran/module/application/DatabaseModule.java new file mode 100644 index 0000000000..4b8028b5b0 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/module/application/DatabaseModule.java @@ -0,0 +1,20 @@ +package com.quran.labs.androidquran.module.application; + +import android.content.Context; + +import com.quran.labs.androidquran.database.BookmarksDBAdapter; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class DatabaseModule { + + @Provides + @Singleton + static BookmarksDBAdapter provideBookmarkDatabaseAdapter(Context context) { + return new BookmarksDBAdapter(context); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/module/application/NetworkModule.java b/app/src/main/java/com/quran/labs/androidquran/module/application/NetworkModule.java new file mode 100644 index 0000000000..5d0003797e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/module/application/NetworkModule.java @@ -0,0 +1,24 @@ +package com.quran.labs.androidquran.module.application; + +import java.util.concurrent.TimeUnit; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import okhttp3.OkHttpClient; + +@Module +public class NetworkModule { + private static final int DEFAULT_READ_TIMEOUT_SECONDS = 20; + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 15; + + @Provides + @Singleton + static OkHttpClient provideOkHttpClient() { + return new OkHttpClient.Builder() + .readTimeout(DEFAULT_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(DEFAULT_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/module/fragment/QuranPageModule.java b/app/src/main/java/com/quran/labs/androidquran/module/fragment/QuranPageModule.java new file mode 100644 index 0000000000..46b2e986e0 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/module/fragment/QuranPageModule.java @@ -0,0 +1,18 @@ +package com.quran.labs.androidquran.module.fragment; + +import dagger.Module; +import dagger.Provides; + +@Module +public class QuranPageModule { + private final Integer[] pages; + + public QuranPageModule(Integer... pages) { + this.pages = pages; + } + + @Provides + Integer[] providePages() { + return this.pages; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/Presenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/Presenter.java new file mode 100644 index 0000000000..77d93ab6cf --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/Presenter.java @@ -0,0 +1,6 @@ +package com.quran.labs.androidquran.presenter; + +public interface Presenter { + void bind(T what); + void unbind(T what); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/QuranImportPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/QuranImportPresenter.java new file mode 100644 index 0000000000..888d826c3c --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/QuranImportPresenter.java @@ -0,0 +1,233 @@ +package com.quran.labs.androidquran.presenter; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.support.v4.app.ActivityCompat; + +import com.quran.labs.androidquran.QuranImportActivity; +import com.quran.labs.androidquran.dao.BookmarkData; +import com.quran.labs.androidquran.model.bookmark.BookmarkImportExportModel; +import com.quran.labs.androidquran.model.bookmark.BookmarkModel; +import com.quran.labs.androidquran.service.util.PermissionUtil; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.concurrent.Callable; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Maybe; +import io.reactivex.MaybeSource; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Function; +import io.reactivex.observers.DisposableMaybeObserver; +import io.reactivex.schedulers.Schedulers; +import okio.BufferedSource; +import okio.Okio; + + +@Singleton +public class QuranImportPresenter implements Presenter { + private static final int REQUEST_WRITE_TO_SDCARD_PERMISSIONS = 1; + + private final Context mAppContext; + private final BookmarkModel mBookmarkModel; + private final BookmarkImportExportModel mBookmarkImportExportModel; + + private boolean mRequestingPermissions; + private Observable mImportObservable; + private QuranImportActivity mCurrentActivity; + + @Inject + QuranImportPresenter(Context appContext, + BookmarkImportExportModel model, + BookmarkModel bookmarkModel) { + mAppContext = appContext; + mBookmarkModel = bookmarkModel; + mBookmarkImportExportModel = model; + } + + private void handleIntent(Intent intent) { + mRequestingPermissions = false; + if (mImportObservable == null) { + Uri uri = intent.getData(); + if (uri == null && intent.getExtras() != null) { + uri = (Uri) intent.getExtras().get(Intent.EXTRA_STREAM); + } + + if (uri != null) { + parseIntentUri(uri); + } else if (mCurrentActivity != null) { + mCurrentActivity.showError(); + } + } else { + subscribeToImportData(); + } + } + + public void importData(final BookmarkData data) { + mImportObservable = mBookmarkModel.importBookmarksObservable(data); + subscribeToImportData(); + } + + public void onPermissionsResult(int requestCode, @NonNull int[] grantResults) { + if (requestCode == REQUEST_WRITE_TO_SDCARD_PERMISSIONS) { + mRequestingPermissions = false; + if (mCurrentActivity != null) { + if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + handleIntent(mCurrentActivity.getIntent()); + } else { + mCurrentActivity.showPermissionsError(); + } + } + } + } + + private void subscribeToImportData() { + mImportObservable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(aBoolean -> { + if (mCurrentActivity != null) { + mCurrentActivity.showImportComplete(); + mImportObservable = null; + } + }); + } + + private void parseIntentUri(final Uri uri) { + getBookmarkDataObservable(parseUri(uri)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new DisposableMaybeObserver() { + @Override + public void onSuccess(BookmarkData bookmarkData) { + if (mCurrentActivity != null) { + mCurrentActivity.showImportConfirmationDialog(bookmarkData); + } + } + + @Override + public void onError(Throwable e) { + if (mCurrentActivity != null) { + handleExternalStorageFile(uri); + } + } + + @Override + public void onComplete() { + if (mCurrentActivity != null) { + handleExternalStorageFile(uri); + } + } + }); + } + + private void handleExternalStorageFile(Uri uri) { + if (PermissionUtil.haveWriteExternalStoragePermission(mAppContext)) { + handleExternalStorageFileInternal(uri); + } else if (mCurrentActivity != null) { + mRequestingPermissions = true; + if (PermissionUtil.canRequestWriteExternalStoragePermission(mCurrentActivity)) { + QuranSettings.getInstance(mAppContext).setSdcardPermissionsDialogPresented(); + ActivityCompat.requestPermissions(mCurrentActivity, + new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE }, + REQUEST_WRITE_TO_SDCARD_PERMISSIONS); + } else { + mCurrentActivity.showPermissionsError(); + } + } + } + + private void handleExternalStorageFileInternal(Uri uri) { + getBookmarkDataObservable(parseExternalFile(uri)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new DisposableMaybeObserver() { + @Override + public void onSuccess(BookmarkData bookmarkData) { + if (mCurrentActivity != null) { + mCurrentActivity.showImportConfirmationDialog(bookmarkData); + } + } + + @Override + public void onError(Throwable e) { + if (mCurrentActivity != null) { + mCurrentActivity.showError(); + } + } + + @Override + public void onComplete() { + if (mCurrentActivity != null) { + mCurrentActivity.showError(); + } + } + }); + } + + private Maybe getBookmarkDataObservable(Maybe source) { + return source + .flatMap(new Function>() { + @Override + public MaybeSource apply(BufferedSource bufferedSource) throws Exception { + return mBookmarkImportExportModel.readBookmarks(bufferedSource).toMaybe(); + } + }) + .subscribeOn(Schedulers.io()); + } + + @NonNull + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + Maybe parseUri(final Uri uri) { + return Maybe.defer(new Callable>() { + @Override + public MaybeSource call() throws Exception { + ParcelFileDescriptor pfd = mAppContext.getContentResolver().openFileDescriptor(uri, "r"); + if (pfd != null) { + FileDescriptor fd = pfd.getFileDescriptor(); + return Maybe.just(Okio.buffer(Okio.source(new FileInputStream(fd)))); + } + return Maybe.empty(); + } + }); + } + + @NonNull + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + Maybe parseExternalFile(final Uri uri) { + return Maybe.defer(new Callable>() { + @Override + public MaybeSource call() throws Exception { + InputStream stream = mAppContext.getContentResolver().openInputStream(uri); + if (stream != null) { + return Maybe.just(Okio.buffer(Okio.source(stream))); + } + return Maybe.empty(); + } + }); + } + + @Override + public void bind(QuranImportActivity activity) { + mCurrentActivity = activity; + if (!activity.isShowingDialog() && !mRequestingPermissions) { + handleIntent(activity.getIntent()); + } + } + + @Override + public void unbind(QuranImportActivity activity) { + if (activity == mCurrentActivity) { + mCurrentActivity = null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/AddTagDialogPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/AddTagDialogPresenter.java new file mode 100644 index 0000000000..55b8b22a93 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/AddTagDialogPresenter.java @@ -0,0 +1,35 @@ +package com.quran.labs.androidquran.presenter.bookmark; + +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.model.bookmark.BookmarkModel; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.fragment.AddTagDialog; + +import javax.inject.Inject; + +public class AddTagDialogPresenter implements Presenter { + private BookmarkModel mBookmarkModel; + + @Inject + AddTagDialogPresenter(BookmarkModel bookmarkModel) { + mBookmarkModel = bookmarkModel; + } + + public void addTag(String tagName) { + mBookmarkModel.addTagObservable(tagName) + .subscribe(); + } + + public void updateTag(Tag tag) { + mBookmarkModel.updateTag(tag) + .subscribe(); + } + + @Override + public void bind(AddTagDialog dialog) { + } + + @Override + public void unbind(AddTagDialog dialog) { + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/BookmarkPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/BookmarkPresenter.java new file mode 100644 index 0000000000..b83ffbd19a --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/BookmarkPresenter.java @@ -0,0 +1,428 @@ +package com.quran.labs.androidquran.presenter.bookmark; + +import android.content.Context; +import android.support.annotation.VisibleForTesting; +import android.support.design.widget.Snackbar; + +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.CustomEvent; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.dao.BookmarkData; +import com.quran.labs.androidquran.dao.RecentPage; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.model.bookmark.BookmarkModel; +import com.quran.labs.androidquran.model.bookmark.BookmarkResult; +import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.fragment.BookmarksFragment; +import com.quran.labs.androidquran.ui.helpers.QuranRow; +import com.quran.labs.androidquran.ui.helpers.QuranRowFactory; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.observers.DisposableSingleObserver; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +@Singleton +public class BookmarkPresenter implements Presenter { + @Snackbar.Duration public static final int DELAY_DELETION_DURATION_IN_MS = 4 * 1000; // 4 seconds + private static final long BOOKMARKS_WITHOUT_TAGS_ID = -1; + + private final Context appContext; + private final BookmarkModel bookmarkModel; + private final QuranSettings quranSettings; + + private int sortOrder; + private boolean groupByTags; + private boolean showRecents; + private BookmarkResult cachedData; + private BookmarksFragment fragment; + private ArabicDatabaseUtils arabicDatabaseUtils; + + private boolean isRtl; + private DisposableSingleObserver pendingRemoval; + private List itemsToRemove; + + @Inject + BookmarkPresenter(Context appContext, + BookmarkModel bookmarkModel, + QuranSettings quranSettings, + ArabicDatabaseUtils arabicDatabaseUtils) { + this.appContext = appContext; + this.quranSettings = quranSettings; + this.bookmarkModel = bookmarkModel; + this.arabicDatabaseUtils = arabicDatabaseUtils; + sortOrder = quranSettings.getBookmarksSortOrder(); + groupByTags = quranSettings.getBookmarksGroupedByTags(); + showRecents = quranSettings.getShowRecents(); + subscribeToChanges(); + } + + void subscribeToChanges() { + Observable.merge(bookmarkModel.tagsObservable(), + bookmarkModel.bookmarksObservable(), bookmarkModel.recentPagesUpdatedObservable()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignore -> { + if (fragment != null) { + requestData(false); + } else { + cachedData = null; + } + }); + } + + public int getSortOrder() { + return sortOrder; + } + + public void setSortOrder(int sortOrder) { + this.sortOrder = sortOrder; + quranSettings.setBookmarksSortOrder(this.sortOrder); + requestData(false); + } + + public void toggleGroupByTags() { + groupByTags = !groupByTags; + quranSettings.setBookmarksGroupedByTags(groupByTags); + requestData(false); + Answers.getInstance().logCustom( + new CustomEvent(groupByTags ? "groupByTags" : "doNotGroupByTags")); + } + + public void toggleShowRecents() { + showRecents = !showRecents; + quranSettings.setShowRecents(showRecents); + requestData(false); + Answers.getInstance().logCustom( + new CustomEvent(showRecents ? "showRecents" : "doNotMinimizeRecents")); + } + + public boolean isShowingRecents() { + return showRecents; + } + + public boolean shouldShowInlineTags() { + return !groupByTags; + } + + public boolean isGroupedByTags() { + return groupByTags; + } + + public boolean[] getContextualOperationsForItems(List rows) { + boolean[] result = new boolean[3]; + + int headers = 0; + int bookmarks = 0; + for (int i = 0, rowsSize = rows.size(); i < rowsSize; i++) { + QuranRow row = rows.get(i); + if (row.isBookmarkHeader()) { + headers++; + } else if (row.isBookmark()) { + bookmarks++; + } + } + + result[0] = headers == 1 && bookmarks == 0; + result[1] = (headers + bookmarks) > 0; + result[2] = headers == 0 && bookmarks > 0; + return result; + } + + public void requestData(boolean canCache) { + if (canCache && cachedData != null) { + if (fragment != null) { + Timber.d("sending cached bookmark data"); + fragment.onNewData(cachedData); + } + } else { + Timber.d("requesting bookmark data from the database"); + getBookmarks(sortOrder, groupByTags); + } + } + + public void deleteAfterSomeTime(List remove) { + // the fragment just called this, so fragment should be valid + fragment.onNewData(predictQuranListAfterDeletion(remove)); + + if (pendingRemoval != null) { + // handle a new delete request when one is already happening by adding those items to delete + // now and un-subscribing from the old request. + if (itemsToRemove != null) { + remove.addAll(itemsToRemove); + } + cancelDeletion(); + } + + itemsToRemove = remove; + pendingRemoval = Single.timer(DELAY_DELETION_DURATION_IN_MS, TimeUnit.MILLISECONDS) + .flatMap(ignore -> removeItemsObservable()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + + @Override + public void onSuccess(BookmarkResult result) { + pendingRemoval = null; + cachedData = result; + if (fragment != null) { + fragment.onNewData(result); + } + } + + @Override + public void onError(Throwable e) { + } + }); + } + + private BookmarkResult predictQuranListAfterDeletion(List remove) { + if (cachedData != null) { + List placeholder = new ArrayList<>(cachedData.rows.size() - remove.size()); + List rows = cachedData.rows; + List removedTags = new ArrayList<>(); + for (int i = 0, rowsSize = rows.size(); i < rowsSize; i++) { + QuranRow row = rows.get(i); + if (!remove.contains(row)) { + placeholder.add(row); + } + } + + for (int i = 0, removedSize = remove.size(); i < removedSize; i++) { + QuranRow row = remove.get(i); + if (row.isHeader() && row.tagId > 0) { + removedTags.add(row.tagId); + } + } + + Map tagMap; + if (removedTags.isEmpty()) { + tagMap = cachedData.tagMap; + } else { + tagMap = new HashMap<>(cachedData.tagMap); + for (int i = 0, removedTagsSize = removedTags.size(); i < removedTagsSize; i++) { + Long tagId = removedTags.get(i); + tagMap.remove(tagId); + } + } + return new BookmarkResult(placeholder, tagMap); + } + return null; + } + + private Single removeItemsObservable() { + return bookmarkModel.removeItemsObservable(new ArrayList<>(itemsToRemove)) + .andThen(getBookmarksListObservable(sortOrder, groupByTags)); + } + + public void cancelDeletion() { + if (pendingRemoval != null) { + pendingRemoval.dispose(); + pendingRemoval = null; + itemsToRemove = null; + } + } + + private Single getBookmarksWithAyatObservable(int sortOrder) { + return bookmarkModel.getBookmarkDataObservable(sortOrder) + .map(bookmarkData -> { + try { + return new BookmarkData(bookmarkData.getTags(), + arabicDatabaseUtils.hydrateAyahText(bookmarkData.getBookmarks()), + bookmarkData.getRecentPages()); + } catch (Exception e) { + return bookmarkData; + } + }); + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + Single getBookmarksListObservable( + int sortOrder, final boolean groupByTags) { + return getBookmarksWithAyatObservable(sortOrder) + .map(bookmarkData -> { + List rows = getBookmarkRows(bookmarkData, groupByTags); + Map tagMap = generateTagMap(bookmarkData.getTags()); + return new BookmarkResult(rows, tagMap); + }) + .subscribeOn(Schedulers.io()); + } + + private void getBookmarks(final int sortOrder, final boolean groupByTags) { + getBookmarksListObservable(sortOrder, groupByTags) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + // notify the ui if we're attached + cachedData = result; + if (fragment != null) { + if (pendingRemoval != null && itemsToRemove != null) { + fragment.onNewData(predictQuranListAfterDeletion(itemsToRemove)); + } else { + fragment.onNewData(result); + } + } + }); + } + + private List getBookmarkRows(BookmarkData data, boolean groupByTags) { + List rows; + + List tags = data.getTags(); + List bookmarks = data.getBookmarks(); + + if (groupByTags) { + rows = getRowsSortedByTags(tags, bookmarks); + } else { + rows = getSortedRows(bookmarks); + } + + List recentPages = data.getRecentPages(); + int size = recentPages.size(); + + if (size > 0) { + if (!showRecents) { + // only show the last bookmark if show recents is off + size = 1; + } + rows.add(0, QuranRowFactory.fromRecentPageHeader(appContext, size)); + for (int i = 0; i < size; i++) { + int page = recentPages.get(i).page; + if (page < Constants.PAGES_FIRST || page > Constants.PAGES_LAST) { + page = 1; + } + rows.add(i + 1, QuranRowFactory.fromCurrentPage(appContext, page)); + } + } + + return rows; + } + + private List getRowsSortedByTags(List tags, List bookmarks) { + List rows = new ArrayList<>(); + // sort by tags, alphabetical + Map> tagsMapping = generateTagsMapping(tags, bookmarks); + for (int i = 0, tagsSize = tags.size(); i < tagsSize; i++) { + Tag tag = tags.get(i); + rows.add(QuranRowFactory.fromTag(tag)); + List tagBookmarks = tagsMapping.get(tag.id); + for (int j = 0, tagBookmarksSize = tagBookmarks.size(); j < tagBookmarksSize; j++) { + rows.add(QuranRowFactory.fromBookmark(appContext, tagBookmarks.get(j), tag.id)); + } + } + + // add untagged bookmarks + List untagged = tagsMapping.get(BOOKMARKS_WITHOUT_TAGS_ID); + if (untagged.size() > 0) { + rows.add(QuranRowFactory.fromNotTaggedHeader(appContext)); + for (int i = 0, untaggedSize = untagged.size(); i < untaggedSize; i++) { + rows.add(QuranRowFactory.fromBookmark(appContext, untagged.get(i))); + } + } + return rows; + } + + private List getSortedRows(List bookmarks) { + List rows = new ArrayList<>(bookmarks.size()); + List ayahBookmarks = new ArrayList<>(); + + // add the page bookmarks directly, save ayah bookmarks for later + for (int i = 0, bookmarksSize = bookmarks.size(); i < bookmarksSize; i++) { + Bookmark bookmark = bookmarks.get(i); + if (bookmark.isPageBookmark()) { + rows.add(QuranRowFactory.fromBookmark(appContext, bookmark)); + } else { + ayahBookmarks.add(bookmark); + } + } + + // add page bookmarks header if needed + if (rows.size() > 0) { + rows.add(0, QuranRowFactory.fromPageBookmarksHeader(appContext)); + } + + // add ayah bookmarks if any + if (ayahBookmarks.size() > 0) { + rows.add(QuranRowFactory.fromAyahBookmarksHeader(appContext)); + for (int i = 0, ayahBookmarksSize = ayahBookmarks.size(); i < ayahBookmarksSize; i++) { + rows.add(QuranRowFactory.fromBookmark(appContext, ayahBookmarks.get(i))); + } + } + + return rows; + } + + private Map> generateTagsMapping( + List tags, List bookmarks) { + Set seenBookmarks = new HashSet<>(); + Map> tagMappings = new HashMap<>(); + for (int i = 0, tagSize = tags.size(); i < tagSize; i++) { + long id = tags.get(i).id; + List matchingBookmarks = new ArrayList<>(); + for (int j = 0, bookmarkSize = bookmarks.size(); j < bookmarkSize; j++) { + Bookmark bookmark = bookmarks.get(j); + if (bookmark.tags.contains(id)) { + matchingBookmarks.add(bookmark); + seenBookmarks.add(bookmark.id); + } + } + tagMappings.put(id, matchingBookmarks); + } + + List untaggedBookmarks = new ArrayList<>(); + for (int i = 0, bookmarksSize = bookmarks.size(); i < bookmarksSize; i++) { + Bookmark bookmark = bookmarks.get(i); + if (!seenBookmarks.contains(bookmark.id)) { + untaggedBookmarks.add(bookmark); + } + } + tagMappings.put(BOOKMARKS_WITHOUT_TAGS_ID, untaggedBookmarks); + + return tagMappings; + } + + private Map generateTagMap(List tags) { + Map tagMap = new HashMap<>(tags.size()); + for (int i = 0, size = tags.size(); i < size; i++) { + Tag tag = tags.get(i); + tagMap.put(tag.id, tag); + } + return tagMap; + } + + + @Override + public void bind(BookmarksFragment fragment) { + this.fragment = fragment; + boolean isRtl = quranSettings.isArabicNames() || QuranUtils.isRtl(); + if (isRtl == this.isRtl) { + requestData(true); + } else { + // don't use the cache if rtl changed + this.isRtl = isRtl; + requestData(false); + } + } + + @Override + public void unbind(BookmarksFragment fragment) { + if (fragment == this.fragment) { + this.fragment = null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/BookmarksContextualModePresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/BookmarksContextualModePresenter.java new file mode 100644 index 0000000000..40e6928c91 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/BookmarksContextualModePresenter.java @@ -0,0 +1,100 @@ +package com.quran.labs.androidquran.presenter.bookmark; + +import android.support.v7.app.AppCompatActivity; +import android.support.v7.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.fragment.BookmarksFragment; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class BookmarksContextualModePresenter implements Presenter { + + private ActionMode actionMode; + private BookmarksFragment fragment; + private AppCompatActivity activity; + + @Inject + BookmarksContextualModePresenter() { + } + + public boolean isInActionMode() { + return actionMode != null; + } + + private void startActionMode() { + if (activity != null) { + actionMode = activity.startSupportActionMode(new ModeCallback()); + } + } + + public void invalidateActionMode(boolean startIfStopped) { + if (actionMode != null) { + actionMode.invalidate(); + } else if (startIfStopped) { + startActionMode(); + } + } + + public void finishActionMode() { + if (actionMode != null) { + actionMode.finish(); + } + } + + private class ModeCallback implements ActionMode.Callback { + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = activity.getMenuInflater(); + inflater.inflate(R.menu.bookmark_contextual_menu, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + if (fragment != null) { + fragment.prepareContextualMenu(menu); + } + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + boolean result = fragment != null && fragment.onContextualActionClicked(item.getItemId()); + finishActionMode(); + return result; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + if (fragment != null) { + fragment.onCloseContextualActionMenu(); + } + + if (mode == actionMode) { + actionMode = null; + } + } + } + + @Override + public void bind(BookmarksFragment fragment) { + this.fragment = fragment; + activity = (AppCompatActivity) fragment.getActivity(); + } + + @Override + public void unbind(BookmarksFragment fragment) { + if (fragment == this.fragment) { + this.fragment = null; + activity = null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/RecentPagePresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/RecentPagePresenter.java new file mode 100644 index 0000000000..5331656309 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/RecentPagePresenter.java @@ -0,0 +1,84 @@ +package com.quran.labs.androidquran.presenter.bookmark; + +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.di.ActivityScope; +import com.quran.labs.androidquran.model.bookmark.RecentPageModel; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.PagerActivity; + +import javax.inject.Inject; + +import io.reactivex.disposables.Disposable; +import io.reactivex.observers.DisposableObserver; + +@ActivityScope +public class RecentPagePresenter implements Presenter { + private final RecentPageModel model; + + private int lastPage; + private int minimumPage; + private int maximumPage; + private Disposable disposable; + + @Inject + RecentPagePresenter(RecentPageModel model) { + this.model = model; + } + + private void onPageChanged(int page) { + model.updateLatestPage(page); + + lastPage = page; + if (minimumPage == Constants.NO_PAGE) { + minimumPage = page; + maximumPage = page; + } else if (page < minimumPage) { + minimumPage = page; + } else if (page > maximumPage) { + maximumPage = page; + } + } + + public void onJump() { + saveAndReset(); + } + + @Override + public void bind(PagerActivity what) { + minimumPage = Constants.NO_PAGE; + maximumPage = Constants.NO_PAGE; + lastPage = Constants.NO_PAGE; + + disposable = what.getViewPagerObservable() + .subscribeWith(new DisposableObserver() { + @Override + public void onNext(Integer value) { + onPageChanged(value); + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + } + }); + } + + @Override + public void unbind(PagerActivity what) { + disposable.dispose(); + saveAndReset(); + } + + private void saveAndReset() { + if (minimumPage != Constants.NO_PAGE || maximumPage != Constants.NO_PAGE) { + model.persistLatestPage(minimumPage, maximumPage, lastPage); + + minimumPage = Constants.NO_PAGE; + maximumPage = Constants.NO_PAGE; + } + lastPage = Constants.NO_PAGE; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/TagBookmarkPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/TagBookmarkPresenter.java new file mode 100644 index 0000000000..ed109c65e8 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/bookmark/TagBookmarkPresenter.java @@ -0,0 +1,197 @@ +package com.quran.labs.androidquran.presenter.bookmark; + +import android.support.v4.util.Pair; + +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.model.bookmark.BookmarkModel; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.fragment.TagBookmarkDialog; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; + + +@Singleton +public class TagBookmarkPresenter implements Presenter { + + private final BookmarkModel bookmarkModel; + private final HashSet checkedTags = new HashSet<>(); + + private TagBookmarkDialog dialog; + + private List tags; + private long[] bookmarkIds; + private boolean madeChanges; + private boolean saveImmediate; + private boolean shouldRefreshTags; + private Bookmark potentialAyahBookmark; + + @Inject + TagBookmarkPresenter(BookmarkModel bookmarkModel) { + this.bookmarkModel = bookmarkModel; + this.bookmarkModel.tagsObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(tag -> { + shouldRefreshTags = true; + if (tags != null && dialog != null) { + // change this if we support updating tags from outside of QuranActivity + tags.add(tags.size() - 1, tag); + checkedTags.add(tag.id); + dialog.setData(tags, checkedTags); + setMadeChanges(); + } + }); + } + + public void setBookmarksMode(long[] bookmarkIds) { + setMode(bookmarkIds, null); + } + + public void setAyahBookmarkMode(int sura, int ayah, int page) { + setMode(null, new Bookmark(-1, sura, ayah, page)); + } + + private void setMode(long[] bookmarkIds, Bookmark potentialAyahBookmark) { + this.bookmarkIds = bookmarkIds; + this.potentialAyahBookmark = potentialAyahBookmark; + saveImmediate = this.potentialAyahBookmark != null; + checkedTags.clear(); + refresh(); + } + + void refresh() { + Single.zip(getTagsObservable(), getBookmarkTagIdsObservable(), Pair::new) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onRefreshedData); + } + + void onRefreshedData(Pair, List> data) { + List tags1 = data.first; + int numberOfTags = tags1.size(); + if (numberOfTags == 0 || tags1.get(numberOfTags - 1).id != -1) { + tags1.add(new Tag(-1, "")); + } + + List bookmarkTags = data.second; + checkedTags.clear(); + for (int i = 0, tagsSize = tags1.size(); i < tagsSize; i++) { + Tag tag = tags1.get(i); + if (bookmarkTags.contains(tag.id)) { + checkedTags.add(tag.id); + } + } + madeChanges = false; + TagBookmarkPresenter.this.tags = tags1; + shouldRefreshTags = false; + if (dialog != null) { + dialog.setData(TagBookmarkPresenter.this.tags, checkedTags); + } + } + + private Single> getTagsObservable() { + if (tags == null || shouldRefreshTags) { + return bookmarkModel.getTagsObservable(); + } else { + return Single.just(tags); + } + } + + public void saveChanges() { + if (madeChanges) { + getBookmarkIdsObservable() + .flatMap(bookmarkIds -> + bookmarkModel.updateBookmarkTags(bookmarkIds, checkedTags, bookmarkIds.length == 1)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(aBoolean -> { + onSaveChangesDone(); + }); + } else { + onSaveChangesDone(); + } + } + + void onSaveChangesDone() { + madeChanges = false; + } + + /** + * Get an Observable with the list of bookmark ids that will be tagged. + * + * @return the list of bookmark ids to tag + */ + private Observable getBookmarkIdsObservable() { + Observable observable; + if (bookmarkIds != null) { + // if we already have a list, we just use that + observable = Observable.just(bookmarkIds); + } else { + // if we don't have a bookmark id, we'll add the bookmark and use its id + observable = bookmarkModel.safeAddBookmark(potentialAyahBookmark.sura, + potentialAyahBookmark.ayah, potentialAyahBookmark.page) + .map(bookmarkId -> new long[]{ bookmarkId }); + } + return observable; + } + + public boolean toggleTag(long id) { + boolean result = false; + if (id > 0) { + if (checkedTags.contains(id)) { + checkedTags.remove(id); + } else { + checkedTags.add(id); + result = true; + } + setMadeChanges(); + } else if (dialog != null) { + dialog.showAddTagDialog(); + } + return result; + } + + void setMadeChanges() { + madeChanges = true; + if (saveImmediate) { + saveChanges(); + } + } + + private Single> getBookmarkTagIdsObservable() { + Single bookmarkId; + if (potentialAyahBookmark != null) { + bookmarkId = bookmarkModel.getBookmarkId(potentialAyahBookmark.sura, + potentialAyahBookmark.ayah, potentialAyahBookmark.page); + } else { + bookmarkId = Single.just( + bookmarkIds != null && bookmarkIds.length == 1 ? bookmarkIds[0] : 0); + } + return bookmarkModel.getBookmarkTagIds(bookmarkId) + .defaultIfEmpty(new ArrayList<>()) + .toSingle(); + } + + @Override + public void bind(TagBookmarkDialog dialog) { + this.dialog = dialog; + if (tags != null) { + // replay the last set of tags and checked tags that we had. + this.dialog.setData(tags, checkedTags); + } + } + + @Override + public void unbind(TagBookmarkDialog dialog) { + if (dialog == this.dialog) { + this.dialog = null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/QuranPagePresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/QuranPagePresenter.java new file mode 100644 index 0000000000..009272eb8b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/QuranPagePresenter.java @@ -0,0 +1,200 @@ +package com.quran.labs.androidquran.presenter.quran; + + +import android.graphics.Bitmap; +import android.graphics.RectF; +import android.support.v4.util.Pair; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.common.Response; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.di.QuranPageScope; +import com.quran.labs.androidquran.model.bookmark.BookmarkModel; +import com.quran.labs.androidquran.model.quran.CoordinatesModel; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.helpers.QuranPageWorker; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import io.reactivex.Completable; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.observers.DisposableObserver; + +@QuranPageScope +public class QuranPagePresenter implements Presenter { + + private final BookmarkModel bookmarkModel; + private final CoordinatesModel coordinatesModel; + private final CompositeDisposable compositeDisposable; + private final QuranSettings quranSettings; + private final QuranPageWorker quranPageWorker; + private final Integer[] pages; + + private QuranPageScreen screen; + private boolean encounteredError; + private boolean didDownloadImages; + + @Inject + QuranPagePresenter(BookmarkModel bookmarkModel, + CoordinatesModel coordinatesModel, + QuranSettings quranSettings, + QuranPageWorker quranPageWorker, + Integer... pages) { + this.bookmarkModel = bookmarkModel; + this.quranSettings = quranSettings; + this.coordinatesModel = coordinatesModel; + this.quranPageWorker = quranPageWorker; + this.compositeDisposable = new CompositeDisposable(); + this.pages = pages; + } + + private void getPageCoordinates(Integer... pages) { + compositeDisposable.add( + Completable.timer(500, TimeUnit.MILLISECONDS) + .andThen(quranSettings.shouldOverlayPageInfo() ? + coordinatesModel.getPageCoordinates(pages) : Observable.empty()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableObserver>() { + @Override + public void onNext(Pair pageBounds) { + if (screen != null) { + screen.setPageCoordinates(pageBounds.first, pageBounds.second); + } + } + + @Override + public void onError(Throwable e) { + encounteredError = true; + if (screen != null) { + screen.setAyahCoordinatesError(); + } + } + + @Override + public void onComplete() { + getAyahCoordinates(pages); + } + })); + } + + private void getBookmarkedAyahs(Integer... pages) { + compositeDisposable.add( + bookmarkModel.getBookmarkedAyahsOnPageObservable(pages) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableObserver>() { + + @Override + public void onNext(List bookmarks) { + if (screen != null) { + screen.setBookmarksOnPage(bookmarks); + } + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + } + })); + } + + private void getAyahCoordinates(Integer... pages) { + compositeDisposable.add( + Observable.fromArray(pages) + .flatMap(coordinatesModel::getAyahCoordinates) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableObserver>>>() { + @Override + public void onNext(Pair>> coordinates) { + if (screen != null) { + screen.setAyahCoordinatesData(coordinates.first, coordinates.second); + } + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + if (quranSettings.shouldHighlightBookmarks()) { + getBookmarkedAyahs(pages); + } + } + }) + ); + } + + public void downloadImages() { + screen.hidePageDownloadError(); + quranPageWorker.loadPages(pages) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableObserver() { + @Override + public void onNext(Response response) { + if (screen != null) { + Bitmap bitmap = response.getBitmap(); + if (bitmap != null) { + didDownloadImages = true; + screen.setPageBitmap(response.getPageNumber(), bitmap); + } else { + didDownloadImages = false; + final int errorCode = response.getErrorCode(); + final int errorRes; + switch (errorCode) { + case Response.ERROR_SD_CARD_NOT_FOUND: + errorRes = R.string.sdcard_error; + break; + case Response.ERROR_DOWNLOADING_ERROR: + errorRes = R.string.download_error_network; + break; + default: + errorRes = R.string.download_error_general; + } + screen.setPageDownloadError(errorRes); + } + } + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + } + }); + } + + public void refresh() { + if (encounteredError) { + encounteredError = false; + getPageCoordinates(pages); + } + } + + @Override + public void bind(QuranPageScreen screen) { + this.screen = screen; + if (!didDownloadImages) { + downloadImages(); + } + getPageCoordinates(pages); + } + + @Override + public void unbind(QuranPageScreen screen) { + this.screen = null; + compositeDisposable.dispose(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/QuranPageScreen.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/QuranPageScreen.java new file mode 100644 index 0000000000..7abbad742e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/QuranPageScreen.java @@ -0,0 +1,22 @@ +package com.quran.labs.androidquran.presenter.quran; + +import android.graphics.Bitmap; +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.dao.Bookmark; + +import java.util.List; +import java.util.Map; + +public interface QuranPageScreen { + void setBookmarksOnPage(List bookmarks); + void setPageCoordinates(int page, RectF pageCoordinates); + void setAyahCoordinatesError(); + void setPageBitmap(int page, @NonNull Bitmap pageBitmap); + void hidePageDownloadError(); + void setPageDownloadError(@StringRes int errorMessage); + void setAyahCoordinatesData(int page, Map> coordinates); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahImageTrackerItem.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahImageTrackerItem.java new file mode 100644 index 0000000000..58e386a775 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahImageTrackerItem.java @@ -0,0 +1,139 @@ +package com.quran.labs.androidquran.presenter.quran.ayahtracker; + +import android.content.Context; +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.ui.helpers.QuranDisplayHelper; +import com.quran.labs.androidquran.ui.util.ImageAyahUtils; +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.widgets.AyahToolBar; +import com.quran.labs.androidquran.widgets.HighlightingImageView; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class AyahImageTrackerItem extends AyahTrackerItem { + private final boolean isPageOnRightSide; + @Nullable Map> coordinates; + + public AyahImageTrackerItem(int page, @NonNull HighlightingImageView highlightingImageView) { + this(page, false, highlightingImageView); + } + + public AyahImageTrackerItem(int page, boolean isPageOnTheRight, + @NonNull HighlightingImageView highlightingImageView) { + super(page, highlightingImageView); + this.isPageOnRightSide = isPageOnTheRight; + } + + @Override + void onSetPageBounds(int page, @NonNull RectF bounds) { + if (this.page == page) { + // this is only called if overlayText is set + ayahView.setPageBounds(bounds); + Context context = ayahView.getContext(); + String suraText = QuranInfo.getSuraNameFromPage(context, page, true); + String juzText = QuranInfo.getJuzString(context, page); + String pageText = QuranUtils.getLocalizedNumber(context, page); + String rub3Text = QuranDisplayHelper.displayRub3(context, page); + ayahView.setOverlayText(suraText, juzText, pageText, rub3Text); + } + } + + @Override + void onSetAyahCoordinates(int page, @NonNull Map> coordinates) { + if (this.page == page) { + this.coordinates = coordinates; + if (!coordinates.isEmpty()) { + ayahView.setCoordinateData(coordinates); + ayahView.invalidate(); + } + } + } + + @Override + void onSetAyahBookmarks(@NonNull List bookmarks) { + int highlighted = 0; + for (int i = 0, size = bookmarks.size(); i < size; i++) { + Bookmark bookmark = bookmarks.get(i); + if (bookmark.page == page) { + highlighted++; + ayahView.highlightAyah(bookmark.sura, bookmark.ayah, HighlightType.BOOKMARK); + } + } + + if (highlighted > 0) { + ayahView.invalidate(); + } + } + + @Override + boolean onHighlightAyah(int page, int sura, int ayah, HighlightType type, boolean scrollToAyah) { + if (this.page == page && coordinates != null) { + ayahView.highlightAyah(sura, ayah, type); + ayahView.invalidate(); + return true; + } else if (coordinates != null) { + ayahView.unHighlight(type); + } + return false; + } + + @Override + void onHighlightAyat(int page, Set ayahKeys, HighlightType type) { + if (this.page == page) { + ayahView.highlightAyat(ayahKeys, type); + ayahView.invalidate(); + } + } + + @Override + void onUnHighlightAyah(int page, int sura, int ayah, HighlightType type) { + if (this.page == page) { + ayahView.unHighlight(sura, ayah, type); + } + } + + @Override + void onUnHighlightAyahType(HighlightType type) { + ayahView.unHighlight(type); + } + + @Override + AyahToolBar.AyahToolBarPosition getToolBarPosition(int page, int sura, int ayah, int toolBarWidth, + int toolBarHeight) { + if (this.page == page) { + final List bounds = coordinates == null ? null : + coordinates.get(sura + ":" + ayah); + final int screenWidth = ayahView.getWidth(); + if (bounds != null && screenWidth > 0) { + final int screenHeight = QuranScreenInfo.getInstance().getHeight(); + AyahToolBar.AyahToolBarPosition position = + ImageAyahUtils.getToolBarPosition(bounds, ayahView.getImageMatrix(), + screenWidth, screenHeight, toolBarWidth, toolBarHeight); + if (isPageOnRightSide) { + // need to adjust offset because our x is really x plus one page + position.x += ayahView.getWidth(); + } + return position; + } + } + return null; + } + + @Nullable + @Override + SuraAyah getAyahForPosition(int page, float x, float y) { + return this.page == page ? + ImageAyahUtils.getAyahFromCoordinates(coordinates, ayahView, x, y) : null; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahScrollableImageTrackerItem.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahScrollableImageTrackerItem.java new file mode 100644 index 0000000000..fc106f1717 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahScrollableImageTrackerItem.java @@ -0,0 +1,60 @@ +package com.quran.labs.androidquran.presenter.quran.ayahtracker; + +import android.graphics.Matrix; +import android.graphics.RectF; +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.ui.util.ImageAyahUtils; +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.widgets.AyahToolBar; +import com.quran.labs.androidquran.widgets.HighlightingImageView; +import com.quran.labs.androidquran.widgets.QuranPageLayout; + +public class AyahScrollableImageTrackerItem extends AyahImageTrackerItem { + @NonNull private QuranPageLayout quranPageLayout; + + public AyahScrollableImageTrackerItem(int page, + @NonNull QuranPageLayout quranPageLayout, + @NonNull HighlightingImageView highlightingImageView) { + super(page, highlightingImageView); + this.quranPageLayout = quranPageLayout; + } + + @Override + boolean onHighlightAyah(int page, int sura, int ayah, HighlightType type, boolean scrollToAyah) { + if (this.page == page && scrollToAyah && coordinates != null) { + final RectF highlightBounds = ImageAyahUtils.getYBoundsForHighlight(coordinates, sura, ayah); + if (highlightBounds != null) { + int screenHeight = QuranScreenInfo.getInstance().getHeight(); + + Matrix matrix = ayahView.getImageMatrix(); + matrix.mapRect(highlightBounds); + + int currentScrollY = quranPageLayout.getCurrentScrollY(); + final boolean topOnScreen = highlightBounds.top > currentScrollY && + highlightBounds.top < currentScrollY + screenHeight; + final boolean bottomOnScreen = highlightBounds.bottom > currentScrollY && + highlightBounds.bottom < currentScrollY + screenHeight; + + if (!topOnScreen || !bottomOnScreen) { + int y = (int) highlightBounds.top - (int) (0.05 * screenHeight); + quranPageLayout.smoothScrollLayoutTo(y); + } + } + } + return super.onHighlightAyah(page, sura, ayah, type, scrollToAyah); + } + + @Override + AyahToolBar.AyahToolBarPosition getToolBarPosition(int page, int sura, int ayah, int toolBarWidth, + int toolBarHeight) { + AyahToolBar.AyahToolBarPosition position = + super.getToolBarPosition(page, sura, ayah, toolBarWidth, toolBarHeight); + if (position != null) { + // If we're in landscape mode (wrapped in SV) update the y-offset + position.yScroll = 0 - quranPageLayout.getCurrentScrollY(); + } + return position; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTrackerItem.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTrackerItem.java new file mode 100644 index 0000000000..4e72bf6d4f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTrackerItem.java @@ -0,0 +1,58 @@ +package com.quran.labs.androidquran.presenter.quran.ayahtracker; + +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.widgets.AyahToolBar; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class AyahTrackerItem { + final int page; + @NonNull final T ayahView; + + AyahTrackerItem(int page, @NonNull T ayahView) { + this.page = page; + this.ayahView = ayahView; + } + + void onSetPageBounds(int page, @NonNull RectF bounds) { + } + + void onSetAyahCoordinates(int page, @NonNull Map> coordinates) { + } + + void onSetAyahBookmarks(@NonNull List bookmarks) { + } + + boolean onHighlightAyah(int page, int sura, int ayah, HighlightType type, boolean scrollToAyah) { + return false; + } + + void onHighlightAyat(int page, Set ayahKeys, HighlightType type) { + } + + void onUnHighlightAyah(int page, int sura, int ayah, HighlightType type) { + } + + void onUnHighlightAyahType(HighlightType type) { + } + + @Nullable + AyahToolBar.AyahToolBarPosition getToolBarPosition(int page, int sura, int ayah, + int toolBarWidth, int toolBarHeight) { + return null; + } + + @Nullable + SuraAyah getAyahForPosition(int page, float x, float y) { + return null; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTrackerPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTrackerPresenter.java new file mode 100644 index 0000000000..a391820b7a --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTrackerPresenter.java @@ -0,0 +1,171 @@ +package com.quran.labs.androidquran.presenter.quran.ayahtracker; + +import android.app.Activity; +import android.graphics.RectF; +import android.support.annotation.Nullable; +import android.view.MotionEvent; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.common.HighlightInfo; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.di.QuranPageScope; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; +import com.quran.labs.androidquran.ui.helpers.AyahTracker; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.widgets.AyahToolBar; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; + +@QuranPageScope +public class AyahTrackerPresenter implements AyahTracker, + Presenter { + private AyahTrackerItem[] items; + private HighlightInfo pendingHighlightInfo; + + @Inject + public AyahTrackerPresenter() { + this.items = new AyahTrackerItem[0]; + } + + public void setPageBounds(int page, RectF bounds) { + for (AyahTrackerItem item : items) { + item.onSetPageBounds(page, bounds); + } + } + + public void setAyahCoordinates(int page, Map> coordinates) { + for (AyahTrackerItem item : items) { + item.onSetAyahCoordinates(page, coordinates); + } + + if (pendingHighlightInfo != null && !coordinates.isEmpty()) { + highlightAyah(pendingHighlightInfo.sura, pendingHighlightInfo.ayah, + pendingHighlightInfo.highlightType, pendingHighlightInfo.scrollToAyah); + } + } + + public void setAyahBookmarks(List bookmarks) { + for (AyahTrackerItem item : items) { + item.onSetAyahBookmarks(bookmarks); + } + } + + @Override + public void highlightAyah(int sura, int ayah, HighlightType type, boolean scrollToAyah) { + boolean handled = false; + int page = items.length == 1 ? items[0].page : QuranInfo.getPageFromSuraAyah(sura, ayah); + for (AyahTrackerItem item : items) { + handled = handled | item.onHighlightAyah(page, sura, ayah, type, scrollToAyah); + } + + if (!handled) { + pendingHighlightInfo = new HighlightInfo(sura, ayah, type, scrollToAyah); + } else { + pendingHighlightInfo = null; + } + } + + @Override + public void highlightAyat(int page, Set ayahKeys, HighlightType type) { + for (AyahTrackerItem item : items) { + item.onHighlightAyat(page, ayahKeys, type); + } + } + + @Override + public void unHighlightAyah(int sura, int ayah, HighlightType type) { + int page = items.length == 1 ? items[0].page : QuranInfo.getPageFromSuraAyah(sura, ayah); + for (AyahTrackerItem item : items) { + item.onUnHighlightAyah(page, sura, ayah, type); + } + } + + @Override + public void unHighlightAyahs(HighlightType type) { + for (AyahTrackerItem item : items) { + item.onUnHighlightAyahType(type); + } + } + + @Override + public AyahToolBar.AyahToolBarPosition getToolBarPosition(int sura, int ayah, + int toolBarWidth, int toolBarHeight) { + int page = items.length == 1 ? items[0].page : QuranInfo.getPageFromSuraAyah(sura, ayah); + for (AyahTrackerItem item : items) { + AyahToolBar.AyahToolBarPosition position = + item.getToolBarPosition(page, sura, ayah, toolBarWidth, toolBarHeight); + if (position != null) { + return position; + } + } + return null; + } + + public boolean handleTouchEvent(Activity activity, MotionEvent event, + AyahSelectedListener.EventType eventType, int page, + AyahSelectedListener ayahSelectedListener, + boolean ayahCoordinatesError) { + if (eventType == AyahSelectedListener.EventType.DOUBLE_TAP) { + unHighlightAyahs(HighlightType.SELECTION); + } else if (ayahSelectedListener.isListeningForAyahSelection(eventType)) { + if (ayahCoordinatesError) { + checkCoordinateData(activity); + } else { + handlePress(event, eventType, page, ayahSelectedListener); + } + return true; + } + return ayahSelectedListener.onClick(eventType); + } + + private void handlePress(MotionEvent ev, AyahSelectedListener.EventType eventType, int page, + AyahSelectedListener ayahSelectedListener) { + SuraAyah result = getAyahForPosition(page, ev.getX(), ev.getY()); + if (result != null && ayahSelectedListener != null) { + ayahSelectedListener.onAyahSelected(eventType, result, this); + } + } + + @Nullable + private SuraAyah getAyahForPosition(int page, float x, float y) { + for (AyahTrackerItem item : items) { + SuraAyah ayah = item.getAyahForPosition(page, x, y); + if (ayah != null) { + return ayah; + } + } + return null; + } + + private void checkCoordinateData(Activity activity) { + if (activity instanceof PagerActivity && + (!QuranFileUtils.haveAyaPositionFile(activity) || + !QuranFileUtils.hasArabicSearchDatabase(activity))) { + PagerActivity pagerActivity = (PagerActivity) activity; + pagerActivity.showGetRequiredFilesDialog(); + } + } + + @Override + public void bind(AyahInteractionHandler interactionHandler) { + this.items = interactionHandler.getAyahTrackerItems(); + } + + @Override + public void unbind(AyahInteractionHandler interactionHandler) { + this.items = new AyahTrackerItem[0]; + } + + public interface AyahInteractionHandler { + AyahTrackerItem[] getAyahTrackerItems(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTranslationTrackerItem.java b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTranslationTrackerItem.java new file mode 100644 index 0000000000..233c2bb133 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/quran/ayahtracker/AyahTranslationTrackerItem.java @@ -0,0 +1,36 @@ +package com.quran.labs.androidquran.presenter.quran.ayahtracker; + +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.ui.translation.TranslationView; + +public class AyahTranslationTrackerItem extends AyahTrackerItem { + + public AyahTranslationTrackerItem(int page, @NonNull TranslationView ayahView) { + super(page, ayahView); + } + + @Override + boolean onHighlightAyah(int page, int sura, int ayah, HighlightType type, boolean scrollToAyah) { + if (this.page == page) { + ayahView.highlightAyah(QuranInfo.getAyahId(sura, ayah)); + return true; + } + ayahView.unhighlightAyat(); + return false; + } + + @Override + void onUnHighlightAyah(int page, int sura, int ayah, HighlightType type) { + if (this.page == page) { + ayahView.unhighlightAyat(); + } + } + + @Override + void onUnHighlightAyahType(HighlightType type) { + ayahView.unhighlightAyat(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/translation/BaseTranslationPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/BaseTranslationPresenter.java new file mode 100644 index 0000000000..1f876db2f5 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/BaseTranslationPresenter.java @@ -0,0 +1,208 @@ +package com.quran.labs.androidquran.presenter.translation; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.common.QuranText; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.data.SuraAyahIterator; +import com.quran.labs.androidquran.data.VerseRange; +import com.quran.labs.androidquran.database.TranslationsDBAdapter; +import com.quran.labs.androidquran.model.translation.TranslationModel; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +class BaseTranslationPresenter implements Presenter { + private final TranslationModel translationModel; + private final TranslationsDBAdapter translationsAdapter; + private final Map translationMap; + + @Nullable T translationScreen; + Disposable disposable; + + BaseTranslationPresenter(TranslationModel translationModel, + TranslationsDBAdapter translationsAdapter) { + this.translationMap = new HashMap<>(); + this.translationModel = translationModel; + this.translationsAdapter = translationsAdapter; + } + + Single getVerses(boolean getArabic, + List translations, + VerseRange verseRange) { + Single>> translationsObservable = + Observable.fromIterable(translations) + .concatMapEager(db -> + translationModel.getTranslationFromDatabase(verseRange, db) + .map(texts -> ensureProperTranslations(verseRange, texts)) + .onErrorReturnItem(new ArrayList<>()) + .toObservable()) + .toList(); + Single> arabicObservable = !getArabic ? Single.just(new ArrayList<>()) : + translationModel.getArabicFromDatabase(verseRange).onErrorReturnItem(new ArrayList<>()); + return Single.zip(arabicObservable, translationsObservable, getTranslationMapSingle(), + (arabic, texts, map) -> { + List ayahInfo = combineAyahData(verseRange, arabic, texts); + String[] translationNames = getTranslationNames(translations, map); + return new ResultHolder(translationNames, ayahInfo); + }) + .subscribeOn(Schedulers.io()); + } + + List getTranslations(QuranSettings quranSettings) { + List results = new ArrayList<>(); + results.addAll(quranSettings.getActiveTranslations()); + return results; + } + + String[] getTranslationNames(@NonNull List translations, + @NonNull Map translationMap) { + int translationCount = translations.size(); + String[] result = new String[translationCount]; + for (int i = 0; i < translationCount; i++) { + String translation = translations.get(i); + LocalTranslation localTranslation = translationMap.get(translation); + result[i] = localTranslation == null ? translation : localTranslation.getTranslatorName(); + } + return result; + } + + @NonNull + List combineAyahData(@NonNull VerseRange verseRange, + @NonNull List arabic, + @NonNull List> texts) { + final int arabicSize = arabic.size(); + final int translationCount = texts.size(); + List result = new ArrayList<>(); + if (translationCount > 0) { + final int verses = arabicSize == 0 ? verseRange.versesInRange : arabicSize; + for (int i = 0; i < verses; i++) { + QuranText element = arabicSize == 0 ? null : arabic.get(i); + final List ayahTranslations = new ArrayList<>(); + for (int j = 0; j < translationCount; j++) { + QuranText item = texts.get(j).size() > i ? texts.get(j).get(i) : null; + if (item != null) { + ayahTranslations.add(texts.get(j).get(i).text); + element = item; + } else { + // this keeps the translations aligned with their translators + // even when a particular translator doesn't load. + ayahTranslations.add(""); + } + } + + if (element != null) { + String arabicText = arabicSize == 0 ? null : arabic.get(i).text; + result.add( + new QuranAyahInfo(element.sura, element.ayah, arabicText, ayahTranslations)); + } + } + } else if (arabicSize > 0) { + for (int i = 0; i < arabicSize; i++) { + QuranText arabicItem = arabic.get(i); + result.add(new QuranAyahInfo(arabicItem.sura, arabicItem.ayah, + arabicItem.text, Collections.emptyList())); + } + } + return result; + } + + /** + * Ensures that the list of translations is valid + * In this case, valid means that the number of verses that we have translations for is either + * the same as the verse range, or that it's 0 (i.e. due to an error querying the database). If + * the list has a non-zero length that is less than what the verseRange says, it adds empty + * entries for those. + * + * @param verseRange the range of verses we're trying to get + * @param texts the data we got back from the database + * @return a list of QuranText with a length of either 0 or the verse range + */ + @NonNull + List ensureProperTranslations(@NonNull VerseRange verseRange, + @NonNull List texts) { + int expectedVerses = verseRange.versesInRange; + int textSize = texts.size(); + if (textSize == 0 || textSize == expectedVerses) { + return texts; + } + + // missing some entries for some ayat - this is a work around for bad data in some databases + // ex. ibn katheer is missing 3 records, 1 in each of suras 5, 17, and 87. + SuraAyah start = new SuraAyah(verseRange.startSura, verseRange.startAyah); + SuraAyah end = new SuraAyah(verseRange.endingSura, verseRange.endingAyah); + SuraAyahIterator iterator = new SuraAyahIterator(start, end); + + int i = 0; + while (iterator.next()) { + QuranText item = texts.size() > i ? texts.get(i) : null; + if (item == null || + item.sura != iterator.getSura() || + item.ayah != iterator.getAyah()) { + texts.add(i, new QuranText(iterator.getSura(), iterator.getAyah(), "")); + } + i++; + } + return texts; + } + + @NonNull + private Single> getTranslationMapSingle() { + if (this.translationMap.size() == 0) { + return Single.fromCallable(translationsAdapter::getTranslations) + .map(translations -> { + Map map = new HashMap<>(); + for (int i = 0, size = translations.size(); i < size; i++) { + LocalTranslation translation = translations.get(i); + map.put(translation.filename, translation); + } + return map; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSuccess(map -> { + this.translationMap.clear(); + this.translationMap.putAll(map); + }); + } else { + return Single.just(this.translationMap); + } + } + + static class ResultHolder { + final String[] translations; + final List ayahInformation; + + ResultHolder(String[] translations, List ayahInformation) { + this.translations = translations; + this.ayahInformation = ayahInformation; + } + } + + @Override + public void bind(T what) { + translationScreen = what; + } + + @Override + public void unbind(T what) { + translationScreen = null; + if (disposable != null) { + disposable.dispose(); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/translation/InlineTranslationPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/InlineTranslationPresenter.java new file mode 100644 index 0000000000..b9a7c577de --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/InlineTranslationPresenter.java @@ -0,0 +1,54 @@ +package com.quran.labs.androidquran.presenter.translation; + +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.data.VerseRange; +import com.quran.labs.androidquran.database.TranslationsDBAdapter; +import com.quran.labs.androidquran.model.translation.TranslationModel; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.List; + +import javax.inject.Inject; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.observers.DisposableSingleObserver; + +public class InlineTranslationPresenter extends + BaseTranslationPresenter { + private final QuranSettings quranSettings; + + @Inject + InlineTranslationPresenter(TranslationModel translationModel, + TranslationsDBAdapter dbAdapter, + QuranSettings quranSettings) { + super(translationModel, dbAdapter); + this.quranSettings = quranSettings; + } + + public void refresh(VerseRange verseRange) { + if (disposable != null) { + disposable.dispose(); + } + + disposable = getVerses(false, getTranslations(quranSettings), verseRange) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(ResultHolder result) { + if (translationScreen != null) { + translationScreen.setVerses(result.translations, result.ayahInformation); + } + } + + @Override + public void onError(Throwable e) { + } + }); + } + + public interface TranslationScreen { + void setVerses(@NonNull String[] translations, @NonNull List verses); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/translation/TranslationManagerPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/TranslationManagerPresenter.java new file mode 100644 index 0000000000..530d36bfb7 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/TranslationManagerPresenter.java @@ -0,0 +1,260 @@ +package com.quran.labs.androidquran.presenter.translation; + +import android.content.Context; +import android.support.annotation.VisibleForTesting; +import android.util.SparseArray; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.dao.translation.Translation; +import com.quran.labs.androidquran.dao.translation.TranslationItem; +import com.quran.labs.androidquran.dao.translation.TranslationList; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.database.DatabaseHandler; +import com.quran.labs.androidquran.database.TranslationsDBAdapter; +import com.quran.labs.androidquran.presenter.Presenter; +import com.quran.labs.androidquran.ui.TranslationManagerActivity; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranSettings; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import io.reactivex.Observable; +import io.reactivex.ObservableSource; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.observers.DisposableMaybeObserver; +import io.reactivex.schedulers.Schedulers; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.BufferedSink; +import okio.Okio; +import timber.log.Timber; + +@Singleton +public class TranslationManagerPresenter implements Presenter { + private static final String WEB_SERVICE_ENDPOINT = "data/translations.php?v=3"; + private static final String CACHED_RESPONSE_FILE_NAME = "translations.cache"; + + private final Context appContext; + private final OkHttpClient okHttpClient; + private final QuranSettings quranSettings; + private final TranslationsDBAdapter translationsDBAdapter; + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) String host; + private TranslationManagerActivity currentActivity; + + @Inject + TranslationManagerPresenter(Context appContext, + OkHttpClient okHttpClient, + QuranSettings quranSettings, + TranslationsDBAdapter dbAdapter) { + this.host = Constants.HOST; + this.appContext = appContext; + this.okHttpClient = okHttpClient; + this.quranSettings = quranSettings; + this.translationsDBAdapter = dbAdapter; + } + + public void checkForUpdates() { + getTranslationsList(true); + } + + public void getTranslationsList(boolean forceDownload) { + Observable.concat( + getCachedTranslationListObservable(forceDownload), getRemoteTranslationListObservable()) + .filter(translationList -> translationList.translations != null) + .firstElement() + .filter(translationList -> !translationList.translations.isEmpty()) + .map(translationList -> mergeWithServerTranslations(translationList.translations)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new DisposableMaybeObserver>() { + @Override + public void onSuccess(List translationItems) { + if (currentActivity != null) { + currentActivity.onTranslationsUpdated(translationItems); + } + + // used for marking upgrades, irrespective of whether or not there is a bound activity + boolean updatedTranslations = false; + for (TranslationItem item : translationItems) { + if (item.needsUpgrade()) { + updatedTranslations = true; + break; + } + } + quranSettings.setHaveUpdatedTranslations(updatedTranslations); + } + + @Override + public void onError(Throwable e) { + if (currentActivity != null) { + currentActivity.onErrorDownloadTranslations(); + } + } + + @Override + public void onComplete() { + if (currentActivity != null) { + currentActivity.onErrorDownloadTranslations(); + } + } + }); + } + + public void updateItem(final TranslationItem item) { + Observable.fromCallable(() -> + translationsDBAdapter.writeTranslationUpdates(Collections.singletonList(item)) + ).subscribeOn(Schedulers.io()) + .subscribe(); + } + + Observable getCachedTranslationListObservable(final boolean forceDownload) { + return Observable.defer(new Callable>() { + @Override + public ObservableSource call() throws Exception { + boolean isCacheStale = System.currentTimeMillis() - + quranSettings.getLastUpdatedTranslationDate() > Constants.MIN_TRANSLATION_REFRESH_TIME; + if (forceDownload || isCacheStale) { + return Observable.empty(); + } + + try { + File cachedFile = getCachedFile(); + if (cachedFile.exists()) { + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter jsonAdapter = moshi.adapter(TranslationList.class); + return Observable.just(jsonAdapter.fromJson(Okio.buffer(Okio.source(cachedFile)))); + } + } catch (Exception e) { + Crashlytics.logException(e); + } + return Observable.empty(); + } + }); + } + + Observable getRemoteTranslationListObservable() { + return Observable.fromCallable(() -> { + Request request = new Request.Builder() + .url(host + WEB_SERVICE_ENDPOINT) + .build(); + Response response = okHttpClient.newCall(request).execute(); + + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter jsonAdapter = moshi.adapter(TranslationList.class); + + ResponseBody responseBody = response.body(); + TranslationList result = jsonAdapter.fromJson(responseBody.source()); + responseBody.close(); + return result; + }).doOnNext(translationList -> { + if (translationList.translations != null && !translationList.translations.isEmpty()) { + writeTranslationList(translationList); + } + }); + } + + void writeTranslationList(TranslationList list) { + File cacheFile = getCachedFile(); + try { + File directory = cacheFile.getParentFile(); + boolean directoryExists = directory.mkdirs() || directory.isDirectory(); + if (directoryExists) { + if (cacheFile.exists()) { + cacheFile.delete(); + } + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter jsonAdapter = moshi.adapter(TranslationList.class); + BufferedSink sink = Okio.buffer(Okio.sink(cacheFile)); + jsonAdapter.toJson(sink, list); + sink.close(); + quranSettings.setLastUpdatedTranslationDate(System.currentTimeMillis()); + } + } catch (Exception e) { + cacheFile.delete(); + Crashlytics.logException(e); + } + } + + private File getCachedFile() { + String dir = QuranFileUtils.getQuranDatabaseDirectory(appContext); + return new File(dir + File.separator + CACHED_RESPONSE_FILE_NAME); + } + + private List mergeWithServerTranslations(List serverTranslations) { + List results = new ArrayList<>(serverTranslations.size()); + SparseArray localTranslations = translationsDBAdapter.getTranslationsHash(); + String databaseDir = QuranFileUtils.getQuranDatabaseDirectory(appContext); + + List updates = new ArrayList<>(); + for (int i = 0, count = serverTranslations.size(); i < count; i++) { + Translation translation = serverTranslations.get(i); + LocalTranslation local = localTranslations.get(translation.id); + + File dbFile = new File(databaseDir, translation.filename); + boolean exists = dbFile.exists(); + + TranslationItem item; + if (exists) { + int version = local == null ? getVersionFromDatabase(translation.filename) : local.version; + item = new TranslationItem(translation, version); + } else { + item = new TranslationItem(translation); + } + + if (exists && !item.exists()) { + // delete the file, it has been corrupted + if (dbFile.delete()) { + exists = false; + } + } + + if ((local == null && exists) || (local != null && !exists)) { + updates.add(item); + } + results.add(item); + } + + if (!updates.isEmpty()) { + translationsDBAdapter.writeTranslationUpdates(updates); + } + return results; + } + + private int getVersionFromDatabase(String filename) { + try { + DatabaseHandler handler = DatabaseHandler.getDatabaseHandler(appContext, filename); + if (handler.validDatabase()) { + return handler.getTextVersion(); + } + } catch (Exception e) { + Timber.d(e, "exception opening database: %s", filename); + } + return 0; + } + + + @Override + public void bind(TranslationManagerActivity activity) { + currentActivity = activity; + } + + @Override + public void unbind(TranslationManagerActivity activity) { + if (activity == currentActivity) { + currentActivity = null; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/translation/TranslationPresenter.java b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/TranslationPresenter.java new file mode 100644 index 0000000000..4fb13a84e5 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/translation/TranslationPresenter.java @@ -0,0 +1,79 @@ +package com.quran.labs.androidquran.presenter.translation; + +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.database.TranslationsDBAdapter; +import com.quran.labs.androidquran.di.QuranPageScope; +import com.quran.labs.androidquran.model.translation.TranslationModel; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.List; + +import javax.inject.Inject; + +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.observers.DisposableObserver; + +@QuranPageScope +public class TranslationPresenter extends + BaseTranslationPresenter { + private final Integer[] pages; + private final QuranSettings quranSettings; + + @Inject + TranslationPresenter(TranslationModel translationModel, + QuranSettings quranSettings, + TranslationsDBAdapter translationsAdapter, + Integer... pages) { + super(translationModel, translationsAdapter); + this.pages = pages; + this.quranSettings = quranSettings; + } + + public void refresh() { + if (disposable != null) { + disposable.dispose(); + } + + disposable = Observable.fromArray(pages) + .flatMap(page -> getVerses(quranSettings.wantArabicInTranslationView(), + getTranslations(quranSettings), QuranInfo.getVerseRangeForPage(page)) + .toObservable()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableObserver() { + @Override + public void onNext(ResultHolder result) { + if (translationScreen != null && result.ayahInformation.size() > 0) { + translationScreen.setVerses( + getPage(result.ayahInformation), result.translations, result.ayahInformation); + } + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + } + }); + } + + private int getPage(List result) { + final int page; + if (pages.length == 1) { + page = pages[0]; + } else { + QuranAyahInfo ayahInfo = result.get(0); + page = QuranInfo.getPageFromSuraAyah(ayahInfo.sura, ayahInfo.ayah); + } + return page; + } + + public interface TranslationScreen { + void setVerses(int page, @NonNull String[] translations, @NonNull List verses); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/AudioService.java b/app/src/main/java/com/quran/labs/androidquran/service/AudioService.java new file mode 100644 index 0000000000..f771c7a05e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/AudioService.java @@ -0,0 +1,1261 @@ +/* + * This code is based on the RandomMusicPlayer example from + * the Android Open Source Project samples. It has been modified + * for use in Quran Android. + * + * Copyright (C) 2011 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. + */ + +package com.quran.labs.androidquran.service; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.SQLException; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.ColorDrawable; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.media.MediaPlayer.OnErrorListener; +import android.media.MediaPlayer.OnPreparedListener; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiManager.WifiLock; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.PowerManager; +import android.support.v4.content.ContextCompat; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaButtonReceiver; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.support.v7.app.NotificationCompat; +import android.util.SparseIntArray; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.database.DatabaseUtils; +import com.quran.labs.androidquran.database.SuraTimingDatabaseHandler; +import com.quran.labs.androidquran.service.util.AudioFocusHelper; +import com.quran.labs.androidquran.service.util.AudioFocusable; +import com.quran.labs.androidquran.service.util.AudioRequest; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.RepeatInfo; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.util.AudioUtils; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; + +import timber.log.Timber; + +/** + * Service that handles media playback. This is the Service through which we + * perform all the media handling in our application. It waits for Intents + * (which come from our main activity, {@link PagerActivity}, which signal + * the service to perform specific operations: Play, Pause, Rewind, Skip, etc. + */ +public class AudioService extends Service implements OnCompletionListener, + OnPreparedListener, OnErrorListener, AudioFocusable, MediaPlayer.OnSeekCompleteListener { + + // These are the Intent actions that we are prepared to handle. Notice that + // the fact these constants exist in our class is a mere convenience: what + // really defines the actions our service can handle are the tags + // in the tag for our service in AndroidManifest.xml. + public static final String ACTION_PLAYBACK = "com.quran.labs.androidquran.action.PLAYBACK"; + public static final String ACTION_PLAY = "com.quran.labs.androidquran.action.PLAY"; + public static final String ACTION_PAUSE = "com.quran.labs.androidquran.action.PAUSE"; + public static final String ACTION_STOP = "com.quran.labs.androidquran.action.STOP"; + public static final String ACTION_SKIP = "com.quran.labs.androidquran.action.SKIP"; + public static final String ACTION_REWIND = "com.quran.labs.androidquran.action.REWIND"; + public static final String ACTION_CONNECT = "com.quran.labs.androidquran.action.CONNECT"; + public static final String ACTION_UPDATE_REPEAT = "com.quran.labs.androidquran.action.UPDATE_REPEAT"; + + // pending notification request codes + private static final int REQUEST_CODE_MAIN = 0; + private static final int REQUEST_CODE_PREVIOUS = 1; + private static final int REQUEST_CODE_PAUSE = 2; + private static final int REQUEST_CODE_SKIP = 3; + private static final int REQUEST_CODE_STOP = 4; + private static final int REQUEST_CODE_RESUME = 5; + + public static class AudioUpdateIntent { + + public static final String INTENT_NAME = "com.quran.labs.androidquran.audio.AudioUpdate"; + public static final String STATUS = "status"; + public static final String SURA = "sura"; + public static final String AYAH = "ayah"; + public static final String REPEAT_COUNT = "repeat_count"; + public static final String REQUEST = "request"; + + public static final int STOPPED = 0; + public static final int PLAYING = 1; + public static final int PAUSED = 2; + } + + // The volume we set the media player to when we lose audio focus, but are + // allowed to reduce the volume instead of stopping playback. + public static final float DUCK_VOLUME = 0.1f; + + // our media player + private MediaPlayer mPlayer = null; + + // are we playing an override file (basmalah/isti3atha) + private boolean mPlayerOverride; + + // our AudioFocusHelper object, if it's available (it's available on SDK + // level >= 8). If not available, this will be null. Always check for null + // before using! + private AudioFocusHelper mAudioFocusHelper = null; + + // object representing the current playing request + private AudioRequest mAudioRequest = null; + + // so user can pass in a serializable AudioRequest to the intent + public static final String EXTRA_PLAY_INFO = "com.quran.labs.androidquran.PLAY_INFO"; + + // ignore the passed in play info if we're already playing + public static final String EXTRA_IGNORE_IF_PLAYING = "com.quran.labs.androidquran.IGNORE_IF_PLAYING"; + + // used to override what is playing now (stop then play) + public static final String EXTRA_STOP_IF_PLAYING = "com.quran.labs.androidquran.STOP_IF_PLAYING"; + + // repeat info + public static final String EXTRA_VERSE_REPEAT_COUNT = "com.quran.labs.androidquran.VERSE_REPEAT_COUNT"; + public static final String EXTRA_RANGE_REPEAT_COUNT = "com.quran.labs.androidquran.RANGE_REPEAT_COUNT"; + public static final String EXTRA_RANGE_RESTRICT = "com.quran.labs.androidquran.RANGE_RESTRICT"; + + // indicates the state our service: + private enum State { + Stopped, // media player is stopped and not prepared to play + Preparing, // media player is preparing... + Playing, // playback active (media player ready!). (but the media + // player may actually be paused in this state if we don't have audio + // focus. But we stay in this state so that we know we have to resume + // playback once we get focus back) + Paused // playback paused (media player ready!) + } + + private State mState = State.Stopped; + + // do we have audio focus? + private enum AudioFocus { + NoFocusNoDuck, // we don't have audio focus, and can't duck + NoFocusCanDuck, // we don't have focus, but can play at a low volume + Focused // we have full audio focus + } + + private AudioFocus mAudioFocus = AudioFocus.NoFocusNoDuck; + + // are we already in the foreground + private boolean mIsSetupAsForeground = false; + + // should we stop (after preparing is done) or not + private boolean mShouldStop = false; + + // Wifi lock that we hold when streaming files from the internet, + // in order to prevent the device from shutting off the Wifi radio + private WifiLock mWifiLock; + + // The ID we use for the notification (the onscreen alert that appears + // at the notification area at the top of the screen as an icon -- and + // as text as well if the user expands the notification area). + final int NOTIFICATION_ID = 4; + + private NotificationManager mNotificationManager; + + // TODO: Merge these builders into one + private NotificationCompat.Builder mNotificationBuilder; + private NotificationCompat.Builder mPausedNotificationBuilder; + + private LocalBroadcastManager mBroadcastManager = null; + private MediaSessionCompat mMediaSession; + + private int mGaplessSura = 0; + private int mNotificationColor; + private Bitmap mNotificationIcon; + private Bitmap mDisplayIcon; + private SparseIntArray mGaplessSuraData = null; + private AsyncTask mTimingTask = null; + + public static final int MSG_START_AUDIO = 1; + public static final int MSG_UPDATE_AUDIO_POS = 2; + + private static class ServiceHandler extends Handler { + + private WeakReference mServiceRef; + + public ServiceHandler(AudioService service) { + mServiceRef = new WeakReference<>(service); + } + + @Override + public void handleMessage(Message msg) { + final AudioService service = mServiceRef.get(); + if (service == null || msg == null) { + return; + } + if (msg.what == MSG_START_AUDIO) { + service.configAndStartMediaPlayer(); + } else if (msg.what == MSG_UPDATE_AUDIO_POS) { + service.updateAudioPlayPosition(); + } + } + } + + private Handler mHandler; + + /** + * Makes sure the media player exists and has been reset. This will create + * the media player if needed, or reset the existing media player if one + * already exists. + */ + private void createMediaPlayerIfNeeded() { + if (mPlayer == null) { + mPlayer = new MediaPlayer(); + + // Make sure the media player will acquire a wake-lock while playing. + // If we don't do that, the CPU might go to sleep while the song is + // playing, causing playback to stop. + // + // Remember that to use this, we have to declare the + // android.permission.WAKE_LOCK permission in AndroidManifest.xml. + mPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); + + // we want the media player to notify us when it's ready preparing, + // and when it's done playing: + mPlayer.setOnPreparedListener(this); + mPlayer.setOnCompletionListener(this); + mPlayer.setOnErrorListener(this); + mPlayer.setOnSeekCompleteListener(this); + + mMediaSession.setActive(true); + } else { + Crashlytics.log("resetting mPlayer..."); + mPlayer.reset(); + } + } + + @Override + public void onCreate() { + Timber.i("debug: Creating service"); + mHandler = new ServiceHandler(this); + + final Context appContext = getApplicationContext(); + mWifiLock = ((WifiManager) appContext.getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL, "QuranAudioLock"); + mNotificationManager = (NotificationManager) appContext.getSystemService(NOTIFICATION_SERVICE); + + // create the Audio Focus Helper, if the Audio Focus feature is available + mAudioFocusHelper = new AudioFocusHelper(appContext, this); + + mBroadcastManager = LocalBroadcastManager.getInstance(appContext); + + ComponentName receiver = new ComponentName(this, MediaButtonReceiver.class); + mMediaSession = new MediaSessionCompat(appContext, "QuranMediaSession", receiver, null); + mMediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | + MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); + mMediaSession.setCallback(new MediaSessionCallback()); + + mNotificationColor = ContextCompat.getColor(this, R.color.audio_notification_color); + try { + // for Android Wear, use a 1x1 Bitmap with the notification color + mDisplayIcon = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(mDisplayIcon); + canvas.drawColor(mNotificationColor); + } catch (OutOfMemoryError oom) { + Crashlytics.logException(oom); + } + } + + private class MediaSessionCallback extends MediaSessionCompat.Callback { + + @Override + public void onPlay() { + processPlayRequest(); + } + + @Override + public void onSkipToNext() { + processSkipRequest(); + } + + @Override + public void onSkipToPrevious() { + processRewindRequest(); + } + + @Override + public void onPause() { + processPauseRequest(); + } + + @Override + public void onStop() { + processStopRequest(); + } + } + + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + // handle a crash that occurs where intent comes in as null + if (State.Stopped == mState) { + mHandler.removeCallbacksAndMessages(null); + stopSelf(); + } + return START_NOT_STICKY; + } + + final String action = intent.getAction(); + if (ACTION_CONNECT.equals(action)) { + if (State.Stopped == mState) { + processStopRequest(true); + } else { + int sura = -1; + int ayah = -1; + int repeatCount = -200; + int state = AudioUpdateIntent.PLAYING; + if (State.Paused == mState) { + state = AudioUpdateIntent.PAUSED; + } + + if (mAudioRequest != null) { + sura = mAudioRequest.getCurrentSura(); + ayah = mAudioRequest.getCurrentAyah(); + + final RepeatInfo repeatInfo = mAudioRequest.getRepeatInfo(); + if (repeatInfo != null) { + repeatCount = repeatInfo.getRepeatCount(); + } + } + + Intent updateIntent = new Intent(AudioUpdateIntent.INTENT_NAME); + updateIntent.putExtra(AudioUpdateIntent.STATUS, state); + updateIntent.putExtra(AudioUpdateIntent.SURA, sura); + updateIntent.putExtra(AudioUpdateIntent.AYAH, ayah); + updateIntent.putExtra(AudioUpdateIntent.REPEAT_COUNT, repeatCount); + updateIntent.putExtra(AudioUpdateIntent.REQUEST, mAudioRequest); + + mBroadcastManager.sendBroadcast(updateIntent); + } + } else if (ACTION_PLAYBACK.equals(action)) { + AudioRequest playInfo = intent.getParcelableExtra(EXTRA_PLAY_INFO); + if (playInfo != null) { + if (State.Stopped == mState || + !intent.getBooleanExtra(EXTRA_IGNORE_IF_PLAYING, false)) { + mAudioRequest = playInfo; + Crashlytics.log("audio request has changed..."); + } + } + + if (intent.getBooleanExtra(EXTRA_STOP_IF_PLAYING, false)) { + if (mPlayer != null) { + mPlayer.stop(); + } + mState = State.Stopped; + Crashlytics.log("stop if playing..."); + } + + processTogglePlaybackRequest(); + } else if (ACTION_PLAY.equals(action)) { + processPlayRequest(); + } else if (ACTION_PAUSE.equals(action)) { + processPauseRequest(); + } else if (ACTION_SKIP.equals(action)) { + processSkipRequest(); + } else if (ACTION_STOP.equals(action)) { + processStopRequest(); + } else if (ACTION_REWIND.equals(action)) { + processRewindRequest(); + } else if (ACTION_UPDATE_REPEAT.equals(action)) { + if (mAudioRequest != null) { + // set the repeat info if applicable + final int verseRepeatCount = intent + .getIntExtra(EXTRA_VERSE_REPEAT_COUNT, mAudioRequest.getVerseRepeatCount()); + mAudioRequest.setVerseRepeatCount(verseRepeatCount); + + // set the range repeat count + final int rangeRepeatCount = intent + .getIntExtra(EXTRA_RANGE_REPEAT_COUNT, mAudioRequest.getRangeRepeatCount()); + mAudioRequest.setRangeRepeatCount(rangeRepeatCount); + + // set the enforce range flag + if (intent.hasExtra(EXTRA_RANGE_RESTRICT)) { + final boolean enforceRange = intent.getBooleanExtra(EXTRA_RANGE_RESTRICT, false); + mAudioRequest.setEnforceBounds(enforceRange); + } + } + } else { + MediaButtonReceiver.handleIntent(mMediaSession, intent); + } + + // we don't want the service to restart if killed + return START_NOT_STICKY; + } + + private class ReadGaplessDataTask extends AsyncTask { + + private int mSura = 0; + private String mDatabasePath = null; + + public ReadGaplessDataTask(String database) { + mDatabasePath = database; + } + + @Override + protected SparseIntArray doInBackground(Integer... params) { + int sura = params[0]; + mSura = sura; + + SuraTimingDatabaseHandler db = SuraTimingDatabaseHandler.getDatabaseHandler(mDatabasePath); + SparseIntArray map = null; + + Cursor cursor = null; + try { + cursor = db.getAyahTimings(sura); + Timber.d("got cursor of data"); + + if (cursor != null && cursor.moveToFirst()) { + map = new SparseIntArray(); + do { + int ayah = cursor.getInt(1); + int time = cursor.getInt(2); + map.put(ayah, time); + } + while (cursor.moveToNext()); + } + } catch (SQLException se) { + // don't crash the app if the database is corrupt + Crashlytics.logException(se); + } finally { + DatabaseUtils.closeCursor(cursor); + } + return map; + } + + @Override + protected void onPostExecute(SparseIntArray map) { + mGaplessSura = mSura; + mGaplessSuraData = map; + mTimingTask = null; + } + } + + private int getSeekPosition(boolean isRepeating) { + if (mAudioRequest == null) { + return -1; + } + + if (mGaplessSura == mAudioRequest.getCurrentSura()) { + if (mGaplessSuraData != null) { + int ayah = mAudioRequest.getCurrentAyah(); + Integer time = mGaplessSuraData.get(ayah); + if (ayah == 1 && !isRepeating) { + return mGaplessSuraData.get(0); + } + return time; + } + } + return -1; + } + + private void updateAudioPlayPosition() { + Timber.d("updateAudioPlayPosition"); + + if (mAudioRequest == null) { + return; + } + if (mPlayer != null || mGaplessSuraData == null) { + int sura = mAudioRequest.getCurrentSura(); + int ayah = mAudioRequest.getCurrentAyah(); + + int updatedAyah = ayah; + int maxAyahs = QuranInfo.getNumAyahs(sura); + + if (sura != mGaplessSura) { + return; + } + setState(PlaybackStateCompat.STATE_PLAYING); + int pos = mPlayer.getCurrentPosition(); + Integer ayahTime = mGaplessSuraData.get(ayah); + Timber.d("updateAudioPlayPosition: %d:%d, currently at %d vs expected at %d", + sura, ayah, pos, ayahTime); + + if (ayahTime > pos) { + int iterAyah = ayah; + while (--iterAyah > 0) { + ayahTime = mGaplessSuraData.get(iterAyah); + if (ayahTime <= pos) { + updatedAyah = iterAyah; + break; + } else { + updatedAyah--; + } + } + } else { + int iterAyah = ayah; + while (++iterAyah <= maxAyahs) { + ayahTime = mGaplessSuraData.get(iterAyah); + if (ayahTime > pos) { + updatedAyah = iterAyah - 1; + break; + } else { + updatedAyah++; + } + } + } + + Timber.d("updateAudioPlayPosition: %d:%d, decided ayah should be: %d", + sura, ayah, updatedAyah); + + if (updatedAyah != ayah) { + ayahTime = mGaplessSuraData.get(ayah); + if (Math.abs(pos - ayahTime) < 150) { + // shouldn't change ayahs if the delta is just 150ms... + mHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 150); + return; + } + + SuraAyah nextAyah = mAudioRequest.setCurrentAyah(sura, updatedAyah); + if (nextAyah == null) { + processStopRequest(); + return; + } else if (nextAyah.sura != sura || + nextAyah.ayah != updatedAyah) { + // remove any messages currently in the queue + mHandler.removeCallbacksAndMessages(null); + + // if the ayah hasn't changed, we're repeating the ayah, + // otherwise, we're repeating a range. this variable is + // what determines whether or not we replay the basmallah. + final boolean ayahRepeat = + (ayah == nextAyah.ayah && sura == nextAyah.sura); + + if (ayahRepeat) { + // jump back to the ayah we should repeat and play it + pos = getSeekPosition(true); + mPlayer.seekTo(pos); + } else { + // we're repeating into a different sura + final boolean flag = sura != mAudioRequest.getCurrentSura(); + playAudio(flag); + } + return; + } + + // moved on to next ayah + updateNotification(); + } else { + // if we have end of sura info and we bypassed end of sura + // line, switch the sura. + ayahTime = mGaplessSuraData.get(999); + if (ayahTime > 0 && pos >= ayahTime) { + SuraAyah repeat = mAudioRequest.setCurrentAyah(sura + 1, 1); + if (repeat != null && repeat.sura == sura) { + // remove any messages currently in the queue + mHandler.removeCallbacksAndMessages(null); + + // jump back to the ayah we should repeat and play it + pos = getSeekPosition(false); + mPlayer.seekTo(pos); + } else { + playAudio(true); + } + return; + } + } + + notifyAyahChanged(); + + if (maxAyahs >= (updatedAyah + 1)) { + Integer t = mGaplessSuraData.get(updatedAyah + 1); + t = t - mPlayer.getCurrentPosition(); + Timber.d("updateAudioPlayPosition postingDelayed after: %d", t); + + if (t < 100) { + t = 100; + } else if (t > 10000) { + t = 10000; + } + mHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, t); + } + // if we're on the last ayah, don't do anything - let the file + // complete on its own to avoid getCurrentPosition() bugs. + } + } + + private void processTogglePlaybackRequest() { + if (State.Paused == mState || State.Stopped == mState) { + processPlayRequest(); + } else { + processPauseRequest(); + } + } + + private void processPlayRequest() { + if (mAudioRequest == null) { + return; + } + tryToGetAudioFocus(); + + // actually play the file + + if (State.Stopped == mState) { + if (mAudioRequest.isGapless()) { + if (mTimingTask != null) { + mTimingTask.cancel(true); + } + String dbPath = mAudioRequest.getGaplessDatabaseFilePath(); + mTimingTask = new ReadGaplessDataTask(dbPath); + mTimingTask.execute(mAudioRequest.getCurrentSura()); + } + + // If we're stopped, just go ahead to the next file and start playing + playAudio(mAudioRequest.getCurrentSura() == 9 && mAudioRequest.getCurrentAyah() == 1); + } else if (State.Paused == mState) { + // If we're paused, just continue playback and restore the + // 'foreground service' state. + mState = State.Playing; + setUpAsForeground(); + configAndStartMediaPlayer(false); + notifyAudioStatus(AudioUpdateIntent.PLAYING); + } + } + + private void processPauseRequest() { + if (State.Playing == mState) { + // Pause media player and cancel the 'foreground service' state. + mState = State.Paused; + mHandler.removeCallbacksAndMessages(null); + mPlayer.pause(); + setState(PlaybackStateCompat.STATE_PAUSED); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { + // while paused, we always retain the MediaPlayer + relaxResources(false, true); + } else { + // on jellybean and above, stay in the foreground and + // update the notification. + relaxResources(false, false); + pauseNotification(); + notifyAudioStatus(AudioUpdateIntent.PAUSED); + } + } else if (State.Stopped == mState) { + // if we get a pause while we're already stopped, it means we likely woke up because + // of AudioIntentReceiver, so just stop in this case. + setState(PlaybackStateCompat.STATE_STOPPED); + stopSelf(); + } + } + + private void processRewindRequest() { + if (State.Playing == mState || State.Paused == mState) { + setState(PlaybackStateCompat.STATE_REWINDING); + + int seekTo = 0; + int pos = mPlayer.getCurrentPosition(); + if (mAudioRequest.isGapless()) { + seekTo = getSeekPosition(true); + pos = pos - seekTo; + } + + if (pos > 1500 && !mPlayerOverride) { + mPlayer.seekTo(seekTo); + mState = State.Playing; // in case we were paused + } else { + tryToGetAudioFocus(); + int sura = mAudioRequest.getCurrentSura(); + mAudioRequest.gotoPreviousAyah(); + if (mAudioRequest.isGapless() && sura == mAudioRequest.getCurrentSura()) { + int timing = getSeekPosition(true); + if (timing > -1) { + mPlayer.seekTo(timing); + } + updateNotification(); + mState = State.Playing; // in case we were paused + return; + } + playAudio(); + } + } + } + + private void processSkipRequest() { + if (mAudioRequest == null) { + return; + } + if (State.Playing == mState || State.Paused == mState) { + setState(PlaybackStateCompat.STATE_SKIPPING_TO_NEXT); + + if (mPlayerOverride) { + playAudio(false); + } else { + final int sura = mAudioRequest.getCurrentSura(); + tryToGetAudioFocus(); + mAudioRequest.gotoNextAyah(true); + if (mAudioRequest.isGapless() && sura == mAudioRequest.getCurrentSura()) { + int timing = getSeekPosition(false); + if (timing > -1) { + mPlayer.seekTo(timing); + mState = State.Playing; // in case we were paused + } + updateNotification(); + return; + } + playAudio(); + } + } + } + + private void processStopRequest() { + processStopRequest(false); + } + + private void processStopRequest(boolean force) { + setState(PlaybackStateCompat.STATE_STOPPED); + mHandler.removeCallbacksAndMessages(null); + + if (State.Preparing == mState) { + mShouldStop = true; + relaxResources(false, true); + } + + if (force || State.Playing == mState || State.Paused == mState) { + mState = State.Stopped; + + // let go of all resources... + relaxResources(true, true); + giveUpAudioFocus(); + + // service is no longer necessary. Will be started again if needed. + mHandler.removeCallbacksAndMessages(null); + stopSelf(); + + // stop async task if it's running + if (mTimingTask != null) { + mTimingTask.cancel(true); + } + + // tell the ui we've stopped + notifyAudioStatus(AudioUpdateIntent.STOPPED); + } + } + + private void notifyAyahChanged() { + if (mAudioRequest != null) { + Intent updateIntent = new Intent(AudioUpdateIntent.INTENT_NAME); + updateIntent.putExtra(AudioUpdateIntent.STATUS, AudioUpdateIntent.PLAYING); + updateIntent.putExtra(AudioUpdateIntent.SURA, mAudioRequest.getCurrentSura()); + updateIntent.putExtra(AudioUpdateIntent.AYAH, mAudioRequest.getCurrentAyah()); + mBroadcastManager.sendBroadcast(updateIntent); + + MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, mAudioRequest.getTitle(this)); + if (mPlayer.isPlaying()) { + metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, mPlayer.getDuration()); + } + + if (mDisplayIcon != null) { + metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, mDisplayIcon); + } + mMediaSession.setMetadata(metadataBuilder.build()); + } + } + + private void notifyAudioStatus(int status) { + Intent updateIntent = new Intent(AudioUpdateIntent.INTENT_NAME); + updateIntent.putExtra(AudioUpdateIntent.STATUS, status); + mBroadcastManager.sendBroadcast(updateIntent); + } + + /** + * Releases resources used by the service for playback. This includes the + * "foreground service" status and notification, the wake locks and + * possibly the MediaPlayer. + * + * @param releaseMediaPlayer Indicates whether the Media Player should also + * be released or not + */ + private void relaxResources(boolean releaseMediaPlayer, boolean stopForeground) { + if (stopForeground) { + // stop being a foreground service + stopForeground(true); + mIsSetupAsForeground = false; + } + + // stop and release the Media Player, if it's available + if (releaseMediaPlayer && mPlayer != null) { + mPlayer.reset(); + mPlayer.release(); + mPlayer = null; + mMediaSession.setActive(false); + } + + // we can also release the Wifi lock, if we're holding it + if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + } + + private void giveUpAudioFocus() { + if (mAudioFocus == AudioFocus.Focused && + mAudioFocusHelper != null && mAudioFocusHelper.abandonFocus()) { + mAudioFocus = AudioFocus.NoFocusNoDuck; + } + } + + /** + * Reconfigures MediaPlayer according to audio focus settings and + * starts/restarts it. This method starts/restarts the MediaPlayer + * respecting the current audio focus state. So if we have focus, + * it will play normally; if we don't have focus, it will either + * leave the MediaPlayer paused or set it to a low volume, depending + * on what is allowed by the current focus settings. This method assumes + * mPlayer != null, so if you are calling it, you have to do so from a + * context where you are sure this is the case. + */ + private void configAndStartMediaPlayer() { + configAndStartMediaPlayer(true); + } + + private void configAndStartMediaPlayer(boolean canSeek) { + Timber.d("configAndStartMediaPlayer()"); + if (mAudioFocus == AudioFocus.NoFocusNoDuck) { + // If we don't have audio focus and can't duck, we have to pause, + // even if mState is State.Playing. But we stay in the Playing state + // so that we know we have to resume playback once we get focus back. + if (mPlayer.isPlaying()) { + mPlayer.pause(); + } + return; + } else if (mAudioFocus == AudioFocus.NoFocusCanDuck) { + // we'll be relatively quiet + mPlayer.setVolume(DUCK_VOLUME, DUCK_VOLUME); + } else { + mPlayer.setVolume(1.0f, 1.0f); + } // we can be loud + + if (mShouldStop) { + processStopRequest(); + mShouldStop = false; + return; + } + + if (mPlayerOverride) { + if (!mPlayer.isPlaying()) { + mPlayer.start(); + } + return; + } + + Timber.d("checking if playing..."); + if (!mPlayer.isPlaying()) { + if (canSeek && mAudioRequest.isGapless()) { + int timing = getSeekPosition(false); + if (timing != -1) { + Timber.d("got timing: %d, seeking and updating later...", timing); + mPlayer.seekTo(timing); + return; + } else { + Timber.d("no timing data yet, will try again..."); + // try to play again after 200 ms + mHandler.sendEmptyMessageDelayed(MSG_START_AUDIO, 200); + return; + } + } else if (mAudioRequest.isGapless()) { + mHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 200); + } + + mPlayer.start(); + } + } + + private void tryToGetAudioFocus() { + if (mAudioFocus != AudioFocus.Focused && + mAudioFocusHelper != null && mAudioFocusHelper.requestFocus()) { + mAudioFocus = AudioFocus.Focused; + } + } + + /** + * Starts playing the next file. + */ + private void playAudio() { + playAudio(false); + } + + private void playAudio(boolean playRepeatSeparator) { + mState = State.Stopped; + relaxResources(false, false); // release everything except MediaPlayer + mPlayerOverride = false; + + try { + String url = mAudioRequest == null ? null : mAudioRequest.getUrl(); + if (mAudioRequest == null || url == null) { + Intent updateIntent = new Intent(AudioUpdateIntent.INTENT_NAME); + updateIntent.putExtra(AudioUpdateIntent.STATUS, AudioUpdateIntent.STOPPED); + mBroadcastManager.sendBroadcast(updateIntent); + + processStopRequest(true); // stop everything! + return; + } + + final boolean isStreaming = url.startsWith("http:") || url.startsWith("https:"); + if (!isStreaming) { + File f = new File(url); + if (!f.exists()) { + Intent updateIntent = new Intent(AudioUpdateIntent.INTENT_NAME); + updateIntent.putExtra(AudioUpdateIntent.STATUS, AudioUpdateIntent.STOPPED); + updateIntent.putExtra(EXTRA_PLAY_INFO, mAudioRequest); + mBroadcastManager.sendBroadcast(updateIntent); + + processStopRequest(true); + return; + } + } + + int overrideResource = 0; + if (playRepeatSeparator) { + final int sura = mAudioRequest.getCurrentSura(); + final int ayah = mAudioRequest.getCurrentAyah(); + if (sura != 9 && ayah > 1) { + overrideResource = R.raw.bismillah; + } else if (sura == 9 && + (ayah > 1 || mAudioRequest.needsIsti3athaAudio())) { + overrideResource = R.raw.isti3atha; + } + // otherwise, ayah of 1 will automatically play the file's basmala + } + + Timber.d("okay, we are preparing to play - streaming is: %b", isStreaming); + createMediaPlayerIfNeeded(); + mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + setState(PlaybackStateCompat.STATE_CONNECTING); + + try { + boolean playUrl = true; + if (overrideResource != 0) { + AssetFileDescriptor afd = getResources().openRawResourceFd(overrideResource); + if (afd != null) { + mPlayer.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + afd.close(); + mPlayerOverride = true; + playUrl = false; + } + } + + if (playUrl) { + overrideResource = 0; + mPlayer.setDataSource(url); + } + } catch (IllegalStateException ie) { + Crashlytics.log("IllegalStateException() while " + + "setting data source, trying to reset..."); + if (overrideResource != 0) { + playAudio(false); + return; + } + mPlayer.reset(); + mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mPlayer.setDataSource(url); + } + + mState = State.Preparing; + if (!mIsSetupAsForeground) { + setUpAsForeground(); + } + + // starts preparing the media player in the background. When it's + // done, it will call our OnPreparedListener (that is, the + // onPrepared() method on this class, since we set the listener + // to 'this'). + // + // Until the media player is prepared, we *cannot* call start() on it! + Timber.d("preparingAsync()..."); + Crashlytics.log("prepareAsync: " + overrideResource + ", " + url); + mPlayer.prepareAsync(); + + // If we are streaming from the internet, we want to hold a Wifi lock, + // which prevents the Wifi radio from going to sleep while the song is + // playing. If, on the other hand, we are *not* streaming, we want to + // release the lock if we were holding it before. + if (isStreaming) { + mWifiLock.acquire(); + } else if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + } catch (IOException ex) { + Timber.e("IOException playing file: %s", ex.getMessage()); + ex.printStackTrace(); + } + } + + private void setState(int state) { + long position = 0; + if (mPlayer != null && mPlayer.isPlaying()) { + position = mPlayer.getCurrentPosition(); + } + + PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder(); + builder.setState(state, position, 1.0f); + builder.setActions( + PlaybackStateCompat.ACTION_PLAY | + PlaybackStateCompat.ACTION_STOP | + PlaybackStateCompat.ACTION_REWIND | + PlaybackStateCompat.ACTION_FAST_FORWARD | + PlaybackStateCompat.ACTION_PAUSE | + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS | + PlaybackStateCompat.ACTION_SKIP_TO_NEXT); + mMediaSession.setPlaybackState(builder.build()); + } + + @Override + public void onSeekComplete(MediaPlayer mediaPlayer) { + Timber.d("seek complete! %d vs %d", + mediaPlayer.getCurrentPosition(), mPlayer.getCurrentPosition()); + mPlayer.start(); + mHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 200); + } + + /** Called when media player is done playing current file. */ + @Override + public void onCompletion(MediaPlayer player) { + // The media player finished playing the current file, so + // we go ahead and start the next. + if (mPlayerOverride) { + playAudio(false); + } else { + boolean flag = false; + final int beforeSura = mAudioRequest.getCurrentSura(); + if (mAudioRequest.gotoNextAyah(false)) { + // we actually switched to a different ayah - so if the + // sura changed, then play the basmala if the ayah is + // not the first one (or if we're in sura tawba). + flag = beforeSura != mAudioRequest.getCurrentSura(); + } + playAudio(flag); + } + } + + /** Called when media player is done preparing. */ + @Override + public void onPrepared(MediaPlayer player) { + Timber.d("okay, prepared!"); + + // The media player is done preparing. That means we can start playing! + mState = State.Playing; + if (mShouldStop) { + processStopRequest(); + mShouldStop = false; + return; + } + + // if gapless and sura changed, get the new data + if (mAudioRequest.isGapless()) { + if (mGaplessSura != mAudioRequest.getCurrentSura()) { + if (mTimingTask != null) { + mTimingTask.cancel(true); + } + + String dbPath = mAudioRequest.getGaplessDatabaseFilePath(); + mTimingTask = new ReadGaplessDataTask(dbPath); + mTimingTask.execute(mAudioRequest.getCurrentSura()); + } + } + + if (mPlayerOverride || !mAudioRequest.isGapless()) { + notifyAyahChanged(); + } + + updateNotification(); + configAndStartMediaPlayer(); + } + + /** Updates the notification. */ + void updateNotification() { + mNotificationBuilder.setContentText(mAudioRequest.getTitle(getApplicationContext())); + mNotificationManager.notify(NOTIFICATION_ID, mNotificationBuilder.build()); + } + + void pauseNotification() { + mPausedNotificationBuilder.setContentText(mAudioRequest.getTitle(getApplicationContext())); + mNotificationManager.notify(NOTIFICATION_ID, mPausedNotificationBuilder.build()); + } + + /** + * Configures service as a foreground service. A foreground service + * is a service that's doing something the user is actively aware of + * (such as playing music), and must appear to the user as a notification. + * That's why we create the notification here. + */ + private void setUpAsForeground() { + // clear the "downloading complete" notification (if it exists) + mNotificationManager.cancel(QuranDownloadNotifier.DOWNLOADING_COMPLETE_NOTIFICATION); + + final Context appContext = getApplicationContext(); + final PendingIntent pi = PendingIntent.getActivity( + appContext, REQUEST_CODE_MAIN, new Intent(appContext, PagerActivity.class), + PendingIntent.FLAG_UPDATE_CURRENT); + + final PendingIntent previousIntent = PendingIntent.getService( + appContext, REQUEST_CODE_PREVIOUS, AudioUtils.getAudioIntent(this, ACTION_REWIND), + PendingIntent.FLAG_UPDATE_CURRENT); + final PendingIntent nextIntent = PendingIntent.getService( + appContext, REQUEST_CODE_SKIP, AudioUtils.getAudioIntent(this, ACTION_SKIP), + PendingIntent.FLAG_UPDATE_CURRENT); + final PendingIntent pauseIntent = PendingIntent.getService( + appContext, REQUEST_CODE_PAUSE, AudioUtils.getAudioIntent(this, ACTION_PAUSE), + PendingIntent.FLAG_UPDATE_CURRENT); + final PendingIntent resumeIntent = PendingIntent.getService( + appContext, REQUEST_CODE_RESUME, AudioUtils.getAudioIntent(this, ACTION_PLAYBACK), + PendingIntent.FLAG_UPDATE_CURRENT); + final PendingIntent stopIntent = PendingIntent.getService( + appContext, REQUEST_CODE_STOP, AudioUtils.getAudioIntent(this, ACTION_STOP), + PendingIntent.FLAG_UPDATE_CURRENT); + + // if the notification icon is null, let's try to build it + if (mNotificationIcon == null) { + try { + Resources resources = appContext.getResources(); + Bitmap logo = BitmapFactory.decodeResource(resources, R.drawable.icon); + int iconWidth = logo.getWidth(); + int iconHeight = logo.getHeight(); + ColorDrawable cd = new ColorDrawable(ContextCompat.getColor(appContext, + R.color.audio_notification_background_color)); + Bitmap bitmap = Bitmap.createBitmap(iconWidth * 2, iconHeight * 2, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + cd.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + cd.draw(canvas); + canvas.drawBitmap(logo, iconWidth / 2, iconHeight / 2, null); + mNotificationIcon = bitmap; + } catch (OutOfMemoryError oomError) { + // if this happens, we need to handle it gracefully, since it's not crash worthy. + Crashlytics.logException(oomError); + } + } + + String audioTitle = mAudioRequest.getTitle(getApplicationContext()); + if (mNotificationBuilder == null) { + mNotificationBuilder = new NotificationCompat.Builder(appContext); + mNotificationBuilder + .setSmallIcon(R.drawable.ic_notification) + .setColor(mNotificationColor) + .setOngoing(true) + .setContentTitle(getString(R.string.app_name)) + .setContentIntent(pi) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(R.drawable.ic_previous, getString(R.string.previous), previousIntent) + .addAction(R.drawable.ic_pause, getString(R.string.pause), pauseIntent) + .addAction(R.drawable.ic_next, getString(R.string.next), nextIntent) + .setShowWhen(false) + .setWhen(0) // older platforms seem to ignore setShowWhen(false) + .setLargeIcon(mNotificationIcon) + .setStyle( + new NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1, 2) + .setMediaSession(mMediaSession.getSessionToken())); + } + + mNotificationBuilder.setTicker(audioTitle); + mNotificationBuilder.setContentText(audioTitle); + + if (mPausedNotificationBuilder == null) { + mPausedNotificationBuilder = new NotificationCompat.Builder(appContext); + mPausedNotificationBuilder + .setSmallIcon(R.drawable.ic_notification) + .setColor(mNotificationColor) + .setOngoing(true) + .setContentTitle(getString(R.string.app_name)) + .setContentIntent(pi) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(R.drawable.ic_play, getString(R.string.play), resumeIntent) + .addAction(R.drawable.ic_stop, getString(R.string.stop), stopIntent) + .setShowWhen(false) + .setWhen(0) + .setLargeIcon(mNotificationIcon) + .setStyle( + new NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1) + .setMediaSession(mMediaSession.getSessionToken())); + } + + mPausedNotificationBuilder.setContentText(audioTitle); + + startForeground(NOTIFICATION_ID, mNotificationBuilder.build()); + mIsSetupAsForeground = true; + } + + /** + * Called when there's an error playing media. When this happens, the media + * player goes to the Error state. We warn the user about the error and + * reset the media player. + */ + public boolean onError(MediaPlayer mp, int what, int extra) { + Timber.e("Error: what=%s, extra=%s", String.valueOf(what), String.valueOf(extra)); + + mState = State.Stopped; + relaxResources(true, true); + giveUpAudioFocus(); + return true; // true indicates we handled the error + } + + public void onGainedAudioFocus() { + mAudioFocus = AudioFocus.Focused; + + // restart media player with new focus settings + if (State.Playing == mState) { + configAndStartMediaPlayer(false); + } + } + + public void onLostAudioFocus(boolean canDuck) { + mAudioFocus = canDuck ? AudioFocus.NoFocusCanDuck : AudioFocus.NoFocusNoDuck; + + // start/restart/pause media player with new focus settings + if (mPlayer != null && mPlayer.isPlaying()) { + configAndStartMediaPlayer(false); + } + } + + @Override + public void onDestroy() { + // Service is being killed, so make sure we release our resources + mHandler.removeCallbacksAndMessages(null); + mState = State.Stopped; + relaxResources(true, true); + giveUpAudioFocus(); + mMediaSession.release(); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent arg0) { + return null; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/QuranDownloadService.java b/app/src/main/java/com/quran/labs/androidquran/service/QuranDownloadService.java new file mode 100644 index 0000000000..6eed1e0425 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/QuranDownloadService.java @@ -0,0 +1,670 @@ +package com.quran.labs.androidquran.service; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiManager.WifiLock; +import android.os.Environment; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.StatFs; +import android.support.v4.content.LocalBroadcastManager; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier.NotificationDetails; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier.ProgressIntent; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.util.ZipUtils; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; + +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.Okio; +import timber.log.Timber; + +public class QuranDownloadService extends Service implements + ZipUtils.ZipListener { + + public static final String TAG = "QuranDownloadService"; + public static final String DEFAULT_TAG = "QuranDownload"; + + // intent actions + public static final String ACTION_DOWNLOAD_URL = + "com.quran.labs.androidquran.DOWNLOAD_URL"; + public static final String ACTION_CANCEL_DOWNLOADS = + "com.quran.labs.androidquran.CANCEL_DOWNLOADS"; + public static final String ACTION_RECONNECT = + "com.quran.labs.androidquran.RECONNECT"; + + // extras + public static final String EXTRA_URL = "url"; + public static final String EXTRA_DESTINATION = "destination"; + public static final String EXTRA_NOTIFICATION_NAME = "notificationName"; + public static final String EXTRA_DOWNLOAD_KEY = "downloadKey"; + public static final String EXTRA_REPEAT_LAST_ERROR = "repeatLastError"; + public static final String EXTRA_DOWNLOAD_TYPE = "downloadType"; + public static final String EXTRA_OUTPUT_FILE_NAME = "outputFileName"; + + // extras for range downloads + public static final String EXTRA_START_VERSE = "startVerse"; + public static final String EXTRA_END_VERSE = "endVerse"; + public static final String EXTRA_IS_GAPLESS = "isGapless"; + + // download types (also handler message types) + public static final int DOWNLOAD_TYPE_UNDEF = 0; + public static final int DOWNLOAD_TYPE_PAGES = 1; + public static final int DOWNLOAD_TYPE_AUDIO = 2; + public static final int DOWNLOAD_TYPE_TRANSLATION = 3; + public static final int DOWNLOAD_TYPE_ARABIC_SEARCH_DB = 4; + + // continuation of handler message types + public static final int NO_OP = 9; + + // error prefs + public static final String PREF_LAST_DOWNLOAD_ERROR = "lastDownloadError"; + public static final String PREF_LAST_DOWNLOAD_ITEM = "lastDownloadItem"; + + public static final int BUFFER_SIZE = 4096 * 2; + private static final int WAIT_TIME = 15 * 1000; + private static final int RETRY_COUNT = 3; + private static final String PARTIAL_EXT = ".part"; + + // download method return values + private static final int DOWNLOAD_SUCCESS = 0; + + private Looper mServiceLooper; + private ServiceHandler mServiceHandler; + private QuranDownloadNotifier mNotifier; + + // written from ui thread and read by download thread + private volatile boolean mIsDownloadCanceled; + private LocalBroadcastManager mBroadcastManager; + private QuranSettings mQuranSettings; + private WifiLock mWifiLock; + + private Intent mLastSentIntent = null; + private Map mSuccessfulZippedDownloads = null; + private Map mRecentlyFailedDownloads = null; + + @Inject OkHttpClient mOkHttpClient; + + private final class ServiceHandler extends Handler { + + public ServiceHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + if (msg.obj != null) { + onHandleIntent((Intent) msg.obj); + } + stopSelf(msg.arg1); + } + } + + @Override + public void onCreate() { + super.onCreate(); + HandlerThread thread = new HandlerThread(TAG); + thread.start(); + + Context appContext = getApplicationContext(); + mNotifier = new QuranDownloadNotifier(this); + mWifiLock = ((WifiManager) appContext.getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL, "downloadLock"); + + mServiceLooper = thread.getLooper(); + mServiceHandler = new ServiceHandler(mServiceLooper); + mIsDownloadCanceled = false; + mSuccessfulZippedDownloads = new HashMap<>(); + mRecentlyFailedDownloads = new HashMap<>(); + mQuranSettings = QuranSettings.getInstance(this); + + ((QuranApplication) getApplication()).getApplicationComponent().inject(this); + mBroadcastManager = LocalBroadcastManager.getInstance(appContext); + } + + private void handleOnStartCommand(Intent intent, int startId) { + if (intent != null) { + if (ACTION_CANCEL_DOWNLOADS.equals(intent.getAction())) { + mServiceHandler.removeCallbacksAndMessages(null); + mIsDownloadCanceled = true; + sendNoOpMessage(startId); + } else if (ACTION_RECONNECT.equals(intent.getAction())) { + int type = intent.getIntExtra(EXTRA_DOWNLOAD_TYPE, + DOWNLOAD_TYPE_UNDEF); + Intent currentLast = mLastSentIntent; + int lastType = currentLast == null ? -1 : + currentLast.getIntExtra(EXTRA_DOWNLOAD_TYPE, + DOWNLOAD_TYPE_UNDEF); + + if (type == lastType) { + if (currentLast != null) { + mBroadcastManager.sendBroadcast(currentLast); + } + } else if (mServiceHandler.hasMessages(type)) { + Intent progressIntent = new Intent(ProgressIntent.INTENT_NAME); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_TYPE, type); + progressIntent.putExtra(ProgressIntent.STATE, + ProgressIntent.STATE_DOWNLOADING); + mBroadcastManager.sendBroadcast(progressIntent); + } + sendNoOpMessage(startId); + } else { + // if we are currently downloading, resend the last broadcast + // and don't queue anything + String download = intent.getStringExtra(EXTRA_DOWNLOAD_KEY); + Intent currentLast = mLastSentIntent; + String currentDownload = currentLast == null ? null : + currentLast.getStringExtra(ProgressIntent.DOWNLOAD_KEY); + if (download != null && currentDownload != null && + download.equals(currentDownload)) { + Timber.d("resending last broadcast..."); + mBroadcastManager.sendBroadcast(currentLast); + + String state = currentLast.getStringExtra(ProgressIntent.STATE); + if (!ProgressIntent.STATE_SUCCESS.equals(state) && + !ProgressIntent.STATE_ERROR.equals(state)) { + // re-queue fatal errors and success cases again just in case + // of a race condition in which we miss the error pref and + // miss the success/failure notification and this re-play + sendNoOpMessage(startId); + Timber.d("leaving..."); + return; + } + } + + int what = intent.getIntExtra(EXTRA_DOWNLOAD_TYPE, + DOWNLOAD_TYPE_UNDEF); + + // put the message in the queue + Message msg = mServiceHandler.obtainMessage(); + msg.arg1 = startId; + msg.obj = intent; + msg.what = what; + mServiceHandler.sendMessage(msg); + } + } + } + + /** + * send a no-op message to the handler to ensure + * that the service isn't left running. + * + * @param id the start id + */ + private void sendNoOpMessage(int id) { + Message msg = mServiceHandler.obtainMessage(); + msg.arg1 = id; + msg.obj = null; + msg.what = NO_OP; + mServiceHandler.sendMessage(msg); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + handleOnStartCommand(intent, startId); + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + mServiceLooper.quit(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void onHandleIntent(Intent intent) { + if (ACTION_DOWNLOAD_URL.equals(intent.getAction())) { + String url = intent.getStringExtra(EXTRA_URL); + String key = intent.getStringExtra(EXTRA_DOWNLOAD_KEY); + int type = intent.getIntExtra(EXTRA_DOWNLOAD_TYPE, 0); + String notificationTitle = + intent.getStringExtra(EXTRA_NOTIFICATION_NAME); + + NotificationDetails details = + new NotificationDetails(notificationTitle, key, type); + // check if already downloaded, and if so, send broadcast + boolean isZipFile = url.endsWith(".zip"); + if (isZipFile && mSuccessfulZippedDownloads.containsKey(url)) { + mLastSentIntent = mNotifier.broadcastDownloadSuccessful(details); + return; + } else if (mRecentlyFailedDownloads.containsKey(url)) { + // if recently failed and we want to repeat the last error... + if (intent.getBooleanExtra(EXTRA_REPEAT_LAST_ERROR, false)) { + Intent failedIntent = mRecentlyFailedDownloads.get(url); + if (failedIntent != null) { + // re-broadcast and leave - just in case of race condition + mBroadcastManager.sendBroadcast(failedIntent); + return; + } + } + // otherwise, remove the fact it was an error and retry + else { + mRecentlyFailedDownloads.remove(url); + } + } + mNotifier.resetNotifications(); + + // get the start/end ayah info if it's a ranged download + SuraAyah startAyah = intent.getParcelableExtra(EXTRA_START_VERSE); + SuraAyah endAyah = intent.getParcelableExtra(EXTRA_END_VERSE); + boolean isGapless = intent.getBooleanExtra(EXTRA_IS_GAPLESS, false); + + String outputFile = intent.getStringExtra(EXTRA_OUTPUT_FILE_NAME); + if (outputFile == null) { + outputFile = getFilenameFromUrl(url); + } + String destination = intent.getStringExtra(EXTRA_DESTINATION); + mLastSentIntent = null; + + if (destination == null) { + return; + } + + boolean result; + if (startAyah != null && endAyah != null) { + result = downloadRange(url, destination, startAyah, endAyah, isGapless, details); + } else { + result = download(url, destination, outputFile, details); + } + if (result && isZipFile) { + mSuccessfulZippedDownloads.put(url, true); + } else if (!result) { + mRecentlyFailedDownloads.put(url, mLastSentIntent); + } + mLastSentIntent = null; + } + } + + private boolean download(String urlString, String destination, + String outputFile, + NotificationDetails details) { + // make the directory if it doesn't exist + new File(destination).mkdirs(); + Timber.d("making directory %s", destination); + + details.setFileStatus(1, 1); + + // notify download starting + mLastSentIntent = mNotifier.notifyProgress(details, 0, 0); + boolean result = downloadFileWrapper(urlString, destination, outputFile, details); + if (result) { + mLastSentIntent = mNotifier.notifyDownloadSuccessful(details); + } + return result; + } + + private boolean downloadRange(String urlString, + String destination, + SuraAyah startVerse, + SuraAyah endVerse, + boolean isGapless, + NotificationDetails details) { + details.setIsGapless(isGapless); + new File(destination).mkdirs(); + + int totalAyahs = 0; + int startSura = startVerse.sura; + int startAyah = startVerse.ayah; + int endSura = endVerse.sura; + int endAyah = endVerse.ayah; + + if (isGapless) { + totalAyahs = endSura - startSura + 1; + if (endAyah == 0) { + totalAyahs--; + } + } else { + if (startSura == endSura) { + totalAyahs = endAyah - startAyah + 1; + } else { + // add the number ayahs from suras in between start and end + for (int i = startSura + 1; i < endSura; i++) { + totalAyahs += QuranInfo.getNumAyahs(i); + } + + // add the number of ayahs from the start sura + totalAyahs += QuranInfo.getNumAyahs(startSura) - startAyah + 1; + + // add the number of ayahs from the last sura + totalAyahs += endAyah; + } + } + + Timber.d("downloadRange for %d between %d:%d to %d:%d, gaplessFlag: %s", + totalAyahs, startSura, startAyah, endSura, endAyah, isGapless ? "true" : "false"); + + details.setFileStatus(1, totalAyahs); + mLastSentIntent = mNotifier.notifyProgress(details, 0, 0); + + // extension and filename template don't change + final String singleFileName = + QuranDownloadService.getFilenameFromUrl(urlString); + final int extLocation = singleFileName.lastIndexOf("."); + final String extension = singleFileName.substring(extLocation); + + boolean result; + for (int i = startSura; i <= endSura; i++) { + int lastAyah = QuranInfo.getNumAyahs(i); + if (i == endSura) { + lastAyah = endAyah; + } + int firstAyah = 1; + if (i == startSura) { + firstAyah = startAyah; + } + + details.sura = i; + if (isGapless) { + if (i == endSura && endAyah == 0) { + continue; + } + String destDir = destination + File.separator; + String url = String.format(Locale.US, urlString, i); + Timber.d("gapless asking to download %s to %s", url, destDir); + final String filename = QuranDownloadService.getFilenameFromUrl(url); + if (!new File(destDir, filename).exists()) { + result = downloadFileWrapper(url, destDir, filename, details); + if (!result) { + return false; + } + } + details.currentFile++; + continue; + } + + // same destination directory for ayahs within the same sura + String destDir = destination + File.separator + i + File.separator; + new File(destDir).mkdirs(); + + for (int j = firstAyah; j <= lastAyah; j++) { + details.ayah = j; + String url = String.format(Locale.US, urlString, i, j); + String destFile = j + extension; + if (!new File(destDir, destFile).exists()) { + result = downloadFileWrapper(url, destDir, destFile, details); + if (!result) { + return false; + } + } + + details.currentFile++; + } + } + + if (!isGapless) { + // attempt to download basmallah if it doesn't exist + String destDir = destination + File.separator + 1 + File.separator; + new File(destDir).mkdirs(); + File basmallah = new File(destDir, "1" + extension); + if (!basmallah.exists()) { + Timber.d("basmallah doesn't exist, downloading..."); + String url = String.format(Locale.US, urlString, 1, 1); + String destFile = 1 + extension; + result = downloadFileWrapper(url, destDir, destFile, details); + if (!result) { + return false; + } + } + } + + mLastSentIntent = mNotifier.notifyDownloadSuccessful(details); + + return true; + } + + private boolean downloadFileWrapper(String urlString, String destination, + String outputFile, NotificationDetails details) { + boolean previouslyCorrupted = false; + + int res = DOWNLOAD_SUCCESS; + for (int i = 0; i < RETRY_COUNT; i++) { + if (mIsDownloadCanceled) { + break; + } + + if (i > 0) { + // want to wait before retrying again + try { + Thread.sleep(WAIT_TIME); + } catch (InterruptedException exception) { + // no op + } + mNotifier.resetNotifications(); + } + + mWifiLock.acquire(); + res = startDownload(urlString, destination, outputFile, details); + if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + + if (res == DOWNLOAD_SUCCESS) { + return true; + } else if (res == QuranDownloadNotifier.ERROR_DISK_SPACE || + res == QuranDownloadNotifier.ERROR_PERMISSIONS) { + // critical errors + mNotifier.notifyError(res, true, details); + return false; + } else if (res == QuranDownloadNotifier.ERROR_INVALID_DOWNLOAD) { + // corrupted download + if (!previouslyCorrupted) { + // give one more chance if this is the first time + // this file was corrupted + i--; + previouslyCorrupted = true; + } + + if (i + 1 < RETRY_COUNT) { + notifyError(res, false, details); + } + } + } + + if (mIsDownloadCanceled) { + res = QuranDownloadNotifier.ERROR_CANCELLED; + } + notifyError(res, true, details); + return false; + } + + private int startDownload(String url, String path, + String filename, NotificationDetails notificationInfo) { + if (!QuranUtils.haveInternet(this)) { + notifyError(QuranDownloadNotifier.ERROR_NETWORK, + false, notificationInfo); + return QuranDownloadNotifier.ERROR_NETWORK; + } + final int result = downloadUrl(url, path, filename, notificationInfo); + if (result == DOWNLOAD_SUCCESS) { + if (filename.endsWith("zip")) { + final File actualFile = new File(path, filename); + if (!ZipUtils.unzipFile(actualFile.getAbsolutePath(), + path, notificationInfo, this)) { + return !actualFile.delete() ? + QuranDownloadNotifier.ERROR_PERMISSIONS : + QuranDownloadNotifier.ERROR_INVALID_DOWNLOAD; + } else { + actualFile.delete(); + } + } + } + return result; + } + + private int downloadUrl(String url, String path, String filename, + NotificationDetails notificationInfo) { + final Request.Builder builder = new Request.Builder() + .url(url).tag(DEFAULT_TAG); + final File partialFile = new File(path, filename + PARTIAL_EXT); + final File actualFile = new File(path, filename); + + Timber.d("downloadUrl: trying to download - file %s", + actualFile.exists() ? "exists" : "doesn't exist"); + long downloadedAmount = 0; + if (partialFile.exists()) { + downloadedAmount = partialFile.length(); + Timber.d("downloadUrl: partialFile exists, length: %d", downloadedAmount); + builder.addHeader("Range", "bytes=" + downloadedAmount + "-"); + } + final boolean isZip = filename.endsWith(".zip"); + + Call call = null; + BufferedSource source = null; + try { + final Request request = builder.build(); + call = mOkHttpClient.newCall(request); + final Response response = call.execute(); + if (response.isSuccessful()) { + Crashlytics.log("successful response: " + response.code() + " - " + downloadedAmount); + final BufferedSink sink = Okio.buffer(Okio.appendingSink(partialFile)); + final ResponseBody body = response.body(); + source = body.source(); + final long size = body.contentLength() + downloadedAmount; + + if (!isSpaceAvailable(size + (isZip ? downloadedAmount + size : 0))) { + return QuranDownloadNotifier.ERROR_DISK_SPACE; + } else if (actualFile.exists()) { + if (actualFile.length() == (size + downloadedAmount)) { + // we already downloaded, why are we re-downloading? + return DOWNLOAD_SUCCESS; + } else if (!actualFile.delete()) { + return QuranDownloadNotifier.ERROR_PERMISSIONS; + } + } + + long read; + int loops = 0; + long totalRead = downloadedAmount; + + /* Temporarily log information to try to understand the root cause for the okio exception. + * The exception would happen as a result of an empty buffer, which one would not expect + * here since we would have just gotten a (successful) result back from the web server. + * + * TODO - insha'Allah remove in the next version. + */ + if (!mIsDownloadCanceled && source.exhausted()) { + Crashlytics.log("rc: " + response.code() + + " -- downloaded: " + downloadedAmount + " -- fn: " + filename); + if (partialFile.exists()) { + Crashlytics.log("length of partial file: " + partialFile.length()); + } + Crashlytics.log("request headers=" + request.headers().toString()); + Crashlytics.log("hdrs=" + response.headers().toString()); + final Exception exception = new IllegalStateException("http source exhausted"); + Crashlytics.logException(exception); + } + + while (!mIsDownloadCanceled && !source.exhausted() && + ((read = source.read(sink.buffer(), BUFFER_SIZE)) > 0)) { + totalRead += read; + if (loops++ % 5 == 0) { + mLastSentIntent = mNotifier.notifyProgress(notificationInfo, totalRead, size); + } + sink.flush(); + } + QuranFileUtils.closeQuietly(sink); + + if (mIsDownloadCanceled) { + return QuranDownloadNotifier.ERROR_CANCELLED; + } else if (!partialFile.renameTo(actualFile)) { + return notifyError(QuranDownloadNotifier.ERROR_PERMISSIONS, true, notificationInfo); + } + return DOWNLOAD_SUCCESS; + } else if (response.code() == 416) { + if (!partialFile.delete()) { + return QuranDownloadNotifier.ERROR_PERMISSIONS; + } + return downloadUrl(url, path, filename, notificationInfo); + } + } catch (IOException exception) { + Timber.e(exception, "Failed to download file"); + } catch (SecurityException se) { + Timber.e(se, "Security exception while downloading file"); + } finally { + QuranFileUtils.closeQuietly(source); + } + + return (call != null && call.isCanceled()) ? + QuranDownloadNotifier.ERROR_CANCELLED : + notifyError(QuranDownloadNotifier.ERROR_NETWORK, + false, notificationInfo); + } + + @Override + public void onProcessingProgress( + NotificationDetails details, int processed, int total) { + if (details.totalFiles == 1) { + mLastSentIntent = mNotifier.notifyDownloadProcessing( + details, processed, total); + } + } + + private int notifyError(int errorCode, boolean isFatal, + NotificationDetails details) { + mLastSentIntent = mNotifier.notifyError(errorCode, isFatal, details); + + if (isFatal) { + // write last error in prefs + mQuranSettings.setLastDownloadError(details.key, errorCode); + } + return errorCode; + } + + // TODO: this is actually a bug - we may not be using /sdcard... + // we may not have permission, etc - some devices get a IllegalArgumentException + // because the path passed is /storage/emulated/0, for example. + private boolean isSpaceAvailable(long spaceNeeded) { + try { + StatFs fsStats = new StatFs( + Environment.getExternalStorageDirectory().getAbsolutePath()); + double availableSpace = (double) fsStats.getAvailableBlocks() * + (double) fsStats.getBlockSize(); + + return availableSpace > spaceNeeded; + } catch (Exception e) { + Crashlytics.logException(e); + return true; + } + } + + private static String getFilenameFromUrl(String url) { + int slashIndex = url.lastIndexOf("/"); + if (slashIndex != -1) { + return url.substring(slashIndex + 1); + } + + // should never happen + return url; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/AudioFocusHelper.java b/app/src/main/java/com/quran/labs/androidquran/service/util/AudioFocusHelper.java new file mode 100644 index 0000000000..f96b7c4659 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/AudioFocusHelper.java @@ -0,0 +1,77 @@ +/* + * This code is based on the RandomMusicPlayer example from + * the Android Open Source Project samples. It has been modified + * for use in Quran Android. + * + * Copyright (C) 2011 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. + */ + +package com.quran.labs.androidquran.service.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioManager; +import android.os.Build; + +/** + * Convenience class to deal with audio focus. This class deals with everything related to audio + * focus: it can request and abandon focus, and will intercept focus change events and deliver + * them to a MusicFocusable interface (which, in our case, is implemented by {@link MusicService}). + * + * This class can only be used on SDK level 8 and above, since it uses API features that are not + * available on previous SDK's. + */ +@TargetApi(Build.VERSION_CODES.FROYO) +public class AudioFocusHelper implements AudioManager.OnAudioFocusChangeListener { + AudioManager mAM; + AudioFocusable mFocusable; + + public AudioFocusHelper(Context ctx, AudioFocusable focusable) { + mAM = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE); + mFocusable = focusable; + } + + /** Requests audio focus. Returns whether request was successful or not. */ + public boolean requestFocus() { + return AudioManager.AUDIOFOCUS_REQUEST_GRANTED == + mAM.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + /** Abandons audio focus. Returns whether request was successful or not. */ + public boolean abandonFocus() { + return AudioManager.AUDIOFOCUS_REQUEST_GRANTED == mAM.abandonAudioFocus(this); + } + + /** + * Called by AudioManager on audio focus changes. We implement this by calling our + * MusicFocusable appropriately to relay the message. + */ + public void onAudioFocusChange(int focusChange) { + if (mFocusable == null) return; + switch (focusChange) { + case AudioManager.AUDIOFOCUS_GAIN: + mFocusable.onGainedAudioFocus(); + break; + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + mFocusable.onLostAudioFocus(false); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + mFocusable.onLostAudioFocus(true); + break; + default: + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/AudioFocusable.java b/app/src/main/java/com/quran/labs/androidquran/service/util/AudioFocusable.java new file mode 100644 index 0000000000..630b10e6b2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/AudioFocusable.java @@ -0,0 +1,39 @@ +/* + * This code is based on the RandomMusicPlayer example from + * the Android Open Source Project samples. It has been modified + * for use in Quran Android. + * + * Copyright (C) 2011 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. + */ + +package com.quran.labs.androidquran.service.util; + +/** + * Represents something that can react to audio focus events. We implement this instead of just + * using AudioManager.OnAudioFocusChangeListener because that interface is only available in SDK + * level 8 and above, and we want our application to work on previous SDKs. + */ +public interface AudioFocusable { + /** Signals that audio focus was gained. */ + void onGainedAudioFocus(); + + /** + * Signals that audio focus was lost. + * + * @param canDuck If true, audio can continue in "ducked" mode (low volume). Otherwise, all + * audio must stop. + */ + void onLostAudioFocus(boolean canDuck); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/AudioRequest.java b/app/src/main/java/com/quran/labs/androidquran/service/util/AudioRequest.java new file mode 100644 index 0000000000..ef161ebf55 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/AudioRequest.java @@ -0,0 +1,309 @@ +package com.quran.labs.androidquran.service.util; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; + +import java.util.Locale; + +import timber.log.Timber; + +public abstract class AudioRequest implements Parcelable { + + private String baseUrl = null; + private String gaplessDatabasePath = null; + + // where we started from + private int ayahsInThisSura = 0; + + // min and max sura/ayah + private int minSura; + private int minAyah; + private int maxSura; + private int maxAyah; + + // what we're currently playing + private int currentSura = 0; + private int currentAyah = 0; + + // range repeat info + private RepeatInfo rangeRepeatInfo; + private boolean enforceBounds; + + // did we just play the basmallah? + private boolean justPlayedBasmallah = false; + + // repeat information + private RepeatInfo repeatInfo; + + public abstract boolean haveSuraAyah(int sura, int ayah); + + AudioRequest(String baseUrl, SuraAyah verse) { + this.baseUrl = baseUrl; + final int startSura = verse.sura; + final int startAyah = verse.ayah; + + if (startSura < 1 || startSura > 114 || startAyah < 1) { + throw new IllegalArgumentException(); + } + + currentSura = startSura; + currentAyah = startAyah; + ayahsInThisSura = QuranInfo.SURA_NUM_AYAHS[currentSura - 1]; + + repeatInfo = new RepeatInfo(0); + repeatInfo.setCurrentVerse(currentSura, currentAyah); + + rangeRepeatInfo = new RepeatInfo(0); + } + + AudioRequest(Parcel in) { + this.baseUrl = in.readString(); + this.gaplessDatabasePath = in.readString(); + this.ayahsInThisSura = in.readInt(); + this.minSura = in.readInt(); + this.minAyah = in.readInt(); + this.maxSura = in.readInt(); + this.maxAyah = in.readInt(); + this.currentSura = in.readInt(); + this.currentAyah = in.readInt(); + this.rangeRepeatInfo = in.readParcelable(RepeatInfo.class.getClassLoader()); + this.enforceBounds = in.readByte() != 0; + this.justPlayedBasmallah = in.readByte() != 0; + this.repeatInfo = in.readParcelable(RepeatInfo.class.getClassLoader()); + } + + public boolean needsIsti3athaAudio() { + // TODO base this check on a boolean array in readers.xml + return !isGapless() || gaplessDatabasePath.contains("minshawi_murattal"); + } + + public void setGaplessDatabaseFilePath(String databaseFile) { + gaplessDatabasePath = databaseFile; + } + + public String getGaplessDatabaseFilePath() { + return gaplessDatabasePath; + } + + public boolean isGapless() { + return gaplessDatabasePath != null; + } + + public void setVerseRepeatCount(int count) { + repeatInfo.setRepeatCount(count); + } + + public int getVerseRepeatCount() { + return repeatInfo.getRepeatCount(); + } + + public void setRangeRepeatCount(int rangeRepeatCount) { + rangeRepeatInfo.setRepeatCount(rangeRepeatCount); + } + + public void setEnforceBounds(boolean enforceBounds) { + this.enforceBounds = enforceBounds; + } + + public boolean shouldEnforceBounds() { + return enforceBounds; + } + + public int getRangeRepeatCount() { + return rangeRepeatInfo.getRepeatCount(); + } + + public SuraAyah getRangeStart() { + return new SuraAyah(minSura, minAyah); + } + + public SuraAyah getRangeEnd() { + return new SuraAyah(maxSura, maxAyah); + } + + public RepeatInfo getRepeatInfo() { + return repeatInfo; + } + + public SuraAyah setCurrentAyah(int sura, int ayah) { + Timber.d("got setCurrentAyah of: %d:%d", sura, ayah); + if (repeatInfo.shouldRepeat()) { + repeatInfo.incrementRepeat(); + } else { + currentSura = sura; + currentAyah = ayah; + if (enforceBounds && + ((currentSura == maxSura && currentAyah > maxAyah) || + (currentSura > maxSura))) { + if (rangeRepeatInfo.shouldRepeat()) { + rangeRepeatInfo.incrementRepeat(); + currentSura = minSura; + currentAyah = minAyah; + } else { + return null; + } + } + + if (currentSura >= 1 && currentSura <= 114) { + ayahsInThisSura = QuranInfo.SURA_NUM_AYAHS[currentSura - 1]; + } + repeatInfo.setCurrentVerse(currentSura, currentAyah); + } + return repeatInfo.getCurrentAyah(); + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setPlayBounds(SuraAyah minVerse, SuraAyah maxVerse) { + minSura = minVerse.sura; + minAyah = minVerse.ayah; + maxSura = maxVerse.sura; + maxAyah = maxVerse.ayah; + } + + public SuraAyah getMinAyah() { + return new SuraAyah(minSura, minAyah); + } + + public SuraAyah getMaxAyah() { + return new SuraAyah(maxSura, maxAyah); + } + + public String getUrl() { + if (enforceBounds && + ((maxSura > 0 && currentSura > maxSura) + || (maxAyah > 0 && currentAyah > maxAyah + && currentSura >= maxSura) + || (minSura > 0 && currentSura < minSura) + || (minAyah > 0 && currentAyah < minAyah + && currentSura <= minSura))) { + return null; + } + + if (currentSura > 114 || currentSura < 1) { + return null; + } + + if (isGapless()) { + String url = String.format(Locale.US, baseUrl, currentSura); + Timber.d("isGapless, url: %s", url); + return url; + } + + int sura = currentSura; + int ayah = currentAyah; + if (ayah == 1 && sura != 1 && sura != 9 && !justPlayedBasmallah) { + justPlayedBasmallah = true; + sura = 1; + ayah = 1; + } else { + justPlayedBasmallah = false; + } + + if (justPlayedBasmallah) { + // really if "about to play" bismillah... + if (!haveSuraAyah(currentSura, currentAyah)) { + // if we don't have the first ayah, don't play basmallah + return null; + } + } + + return String.format(Locale.US, baseUrl, sura, ayah); + } + + public String getTitle(Context context) { + return QuranInfo.getSuraAyahString(context, currentSura, currentAyah); + } + + public int getCurrentSura() { + return currentSura; + } + + public int getCurrentAyah() { + return currentAyah; + } + + public boolean gotoNextAyah(boolean force) { + // don't go to next ayah if we haven't played basmallah yet + if (justPlayedBasmallah) { + return false; + } + if (!force && repeatInfo.shouldRepeat()) { + repeatInfo.incrementRepeat(); + if (currentAyah == 1 && currentSura != 1 && currentSura != 9) { + justPlayedBasmallah = true; + } + return false; + } + + if (enforceBounds && ((currentSura > maxSura) || + (currentAyah >= maxAyah && currentSura == maxSura))) { + if (rangeRepeatInfo.shouldRepeat()) { + rangeRepeatInfo.incrementRepeat(); + currentSura = minSura; + currentAyah = minAyah; + repeatInfo.setCurrentVerse(currentSura, currentAyah); + return true; + } + } + + currentAyah++; + if (ayahsInThisSura < currentAyah) { + currentAyah = 1; + currentSura++; + if (currentSura <= 114) { + ayahsInThisSura = QuranInfo.SURA_NUM_AYAHS[currentSura - 1]; + repeatInfo.setCurrentVerse(currentSura, currentAyah); + } + } else { + repeatInfo.setCurrentVerse(currentSura, currentAyah); + } + return true; + } + + public void gotoPreviousAyah() { + currentAyah--; + if (currentAyah < 1) { + currentSura--; + if (currentSura > 0) { + ayahsInThisSura = QuranInfo.SURA_NUM_AYAHS[currentSura - 1]; + currentAyah = ayahsInThisSura; + } + } else if (currentAyah == 1 && !isGapless()) { + justPlayedBasmallah = true; + } + + if (currentSura > 0 && currentAyah > 0) { + repeatInfo.setCurrentVerse(currentSura, currentAyah); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(this.baseUrl); + dest.writeString(this.gaplessDatabasePath); + dest.writeInt(this.ayahsInThisSura); + dest.writeInt(this.minSura); + dest.writeInt(this.minAyah); + dest.writeInt(this.maxSura); + dest.writeInt(this.maxAyah); + dest.writeInt(this.currentSura); + dest.writeInt(this.currentAyah); + dest.writeParcelable(this.rangeRepeatInfo, 0); + dest.writeByte(enforceBounds ? (byte) 1 : (byte) 0); + dest.writeByte(justPlayedBasmallah ? (byte) 1 : (byte) 0); + dest.writeParcelable(this.repeatInfo, 0); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/DefaultDownloadReceiver.java b/app/src/main/java/com/quran/labs/androidquran/service/util/DefaultDownloadReceiver.java new file mode 100644 index 0000000000..a6b765c4ec --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/DefaultDownloadReceiver.java @@ -0,0 +1,310 @@ +package com.quran.labs.androidquran.service.util; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.service.QuranDownloadService; + +import android.app.ProgressDialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Handler; +import android.os.Message; + +import java.lang.ref.WeakReference; +import java.text.DecimalFormat; + +public class DefaultDownloadReceiver extends BroadcastReceiver { + + private int mDownloadType = -1; + private SimpleDownloadListener mListener; + private ProgressDialog mProgressDialog; + private Context mContext = null; + private boolean mDidReceiveBroadcast; + private boolean mCanCancelDownload; + + private static class DownloadReceiverHandler extends Handler { + private final WeakReference mReceiverRef; + + public DownloadReceiverHandler(DefaultDownloadReceiver receiver) { + mReceiverRef = new WeakReference<>(receiver); + } + + @Override + public void handleMessage(Message msg) { + final DefaultDownloadReceiver receiver = mReceiverRef.get(); + if (receiver == null || receiver.mListener == null) { + return; + } + + Intent intent = (Intent) msg.obj; + String state = intent.getStringExtra( + QuranDownloadNotifier.ProgressIntent.STATE); + switch (state) { + case QuranDownloadNotifier.ProgressIntent.STATE_SUCCESS: + receiver.dismissDialog(); + receiver.mListener.handleDownloadSuccess(); + break; + case QuranDownloadNotifier.ProgressIntent.STATE_ERROR: { + int msgId = ServiceIntentHelper. + getErrorResourceFromDownloadIntent(intent, true); + receiver.dismissDialog(); + receiver.mListener.handleDownloadFailure(msgId); + break; + } + case QuranDownloadNotifier.ProgressIntent.STATE_DOWNLOADING: { + int progress = intent.getIntExtra( + QuranDownloadNotifier.ProgressIntent.PROGRESS, -1); + long downloadedSize = intent.getLongExtra( + QuranDownloadNotifier.ProgressIntent.DOWNLOADED_SIZE, -1); + long totalSize = intent.getLongExtra( + QuranDownloadNotifier.ProgressIntent.TOTAL_SIZE, -1); + int sura = intent.getIntExtra(QuranDownloadNotifier.ProgressIntent.SURA, -1); + int ayah = intent.getIntExtra(QuranDownloadNotifier.ProgressIntent.AYAH, -1); + if (receiver.mListener instanceof DownloadListener) { + ((DownloadListener) receiver.mListener).updateDownloadProgress(progress, + downloadedSize, totalSize); + } else { + receiver.updateDownloadProgress(progress, downloadedSize, totalSize, sura, ayah); + } + break; + } + case QuranDownloadNotifier.ProgressIntent.STATE_PROCESSING: { + int progress = intent.getIntExtra( + QuranDownloadNotifier.ProgressIntent.PROGRESS, -1); + int processedFiles = intent.getIntExtra( + QuranDownloadNotifier.ProgressIntent.PROCESSED_FILES, 0); + int totalFiles = intent.getIntExtra( + QuranDownloadNotifier.ProgressIntent.TOTAL_FILES, 0); + if (receiver.mListener instanceof DownloadListener) { + ((DownloadListener) receiver.mListener).updateProcessingProgress(progress, + processedFiles, totalFiles); + } else { + receiver.updateProcessingProgress(progress, processedFiles, totalFiles); + } + break; + } + case QuranDownloadNotifier.ProgressIntent + .STATE_ERROR_WILL_RETRY: { + int msgId = ServiceIntentHelper. + getErrorResourceFromDownloadIntent(intent, true); + if (receiver.mListener instanceof DownloadListener) { + ((DownloadListener) receiver.mListener) + .handleDownloadTemporaryError(msgId); + } else { + receiver.handleNonFatalError(msgId); + } + break; + } + } + } + } + + private final Handler mHandler; + + public DefaultDownloadReceiver(Context context, int downloadType) { + mContext = context; + mDownloadType = downloadType; + mHandler = new DownloadReceiverHandler(this); + } + + public void setCanCancelDownload(boolean canCancel) { + mCanCancelDownload = canCancel; + } + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + int type = intent.getIntExtra( + QuranDownloadNotifier.ProgressIntent.DOWNLOAD_TYPE, + QuranDownloadService.DOWNLOAD_TYPE_UNDEF); + String state = intent.getStringExtra( + QuranDownloadNotifier.ProgressIntent.STATE); + + if (mDownloadType != type || state == null) { + return; + } + + mDidReceiveBroadcast = true; + Message msg = mHandler.obtainMessage(); + msg.obj = intent; + + // only care about the latest download progress + mHandler.removeCallbacksAndMessages(null); + + // send the message at the front of the queue + mHandler.sendMessageAtFrontOfQueue(msg); + } + + private void dismissDialog() { + if (mProgressDialog != null) { + try { + mProgressDialog.dismiss(); + } catch (Exception e) { + // no op + } + mProgressDialog = null; + } + } + + public boolean didReceiveBroadcast() { + return mDidReceiveBroadcast; + } + + private void makeAndShowProgressDialog() { + makeProgressDialog(); + if (mProgressDialog != null) { + mProgressDialog.show(); + } + } + + private void makeProgressDialog() { + if (mProgressDialog == null) { + mProgressDialog = new ProgressDialog(mContext); + mProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + mProgressDialog.setCancelable(mCanCancelDownload); + if (mCanCancelDownload) { + mProgressDialog.setOnCancelListener( + new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + cancelDownload(); + } + }); + mProgressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, + mContext.getString(R.string.cancel), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + cancelDownload(); + } + }); + } + + mProgressDialog.setTitle(R.string.downloading_title); + mProgressDialog.setMessage(mContext.getString( + R.string.downloading_message)); + } + } + + private void cancelDownload() { + Intent i = new Intent(mContext, QuranDownloadService.class); + i.setAction(QuranDownloadService.ACTION_CANCEL_DOWNLOADS); + mContext.startService(i); + } + + private void updateDownloadProgress(int progress, + long downloadedSize, long totalSize, int currentSura, int currentAyah) { + if (mProgressDialog == null) { + makeAndShowProgressDialog(); + } + if (mProgressDialog != null) { + if (!mProgressDialog.isShowing()) { + mProgressDialog.show(); + } + if (progress == -1) { + int titleId = R.string.downloading_title; + mProgressDialog.setIndeterminate(true); + mProgressDialog.setMessage(mContext.getString(titleId)); + return; + } + + mProgressDialog.setIndeterminate(false); + mProgressDialog.setMax(100); + mProgressDialog.setProgress(progress); + + DecimalFormat df = new DecimalFormat("###.00"); + int mb = 1024 * 1024; + String downloaded = mContext.getString(R.string.prefs_megabytes_str, + df.format((1.0 * downloadedSize / mb))); + String total = mContext.getString(R.string.prefs_megabytes_str, + df.format((1.0 * totalSize / mb))); + + String message; + if (currentSura < 1) { + message = String.format( + mContext.getString(R.string.download_progress), + downloaded, total); + } else if (currentAyah <= 0) { + message = String.format( + mContext.getString(R.string.download_sura_progress), + downloaded, total, currentSura); + } else { + message = String.format(mContext.getString(R.string.download_sura_ayah_progress), + currentSura, currentAyah); + } + mProgressDialog.setMessage(message); + } + } + + private void updateProcessingProgress(int progress, + int processedFiles, int totalFiles) { + if (mProgressDialog == null) { + makeAndShowProgressDialog(); + } + if (mProgressDialog != null) { + if (!mProgressDialog.isShowing()) { + mProgressDialog.show(); + } + if (progress == -1) { + int titleId = R.string.extracting_title; + mProgressDialog.setIndeterminate(true); + mProgressDialog.setMessage(mContext.getString(titleId)); + return; + } + + mProgressDialog.setIndeterminate(false); + mProgressDialog.setMax(100); + mProgressDialog.setProgress(progress); + + mProgressDialog.setMessage(mContext.getString(R.string.extracting_title)); + + String message = String.format( + mContext.getString(R.string.process_progress), + processedFiles, totalFiles); + mProgressDialog.setMessage(message); + } + } + + private void handleNonFatalError(int msgId) { + if (mProgressDialog == null) { + makeAndShowProgressDialog(); + } + if (mProgressDialog != null) { + if (!mProgressDialog.isShowing()) { + mProgressDialog.show(); + } + mProgressDialog.setMessage(mContext.getString(msgId)); + } + } + + public void setListener(SimpleDownloadListener listener) { + mListener = listener; + if (mListener == null && mProgressDialog != null) { + mProgressDialog.dismiss(); + mProgressDialog = null; + } else if (mListener != null) { + makeProgressDialog(); + } + } + + public interface SimpleDownloadListener { + + void handleDownloadSuccess(); + + void handleDownloadFailure(int errId); + } + + public interface DownloadListener extends SimpleDownloadListener { + + void updateDownloadProgress(int progress, + long downloadedSize, long totalSize); + + void updateProcessingProgress(int progress, + int processFiles, int totalFiles); + + void handleDownloadTemporaryError(int errorId); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/DownloadAudioRequest.java b/app/src/main/java/com/quran/labs/androidquran/service/util/DownloadAudioRequest.java new file mode 100644 index 0000000000..48de419ed2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/DownloadAudioRequest.java @@ -0,0 +1,63 @@ +package com.quran.labs.androidquran.service.util; + +import android.os.Parcel; +import android.support.annotation.NonNull; + +import com.quran.labs.androidquran.common.QariItem; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.util.AudioUtils; + +public class DownloadAudioRequest extends AudioRequest { + + @NonNull private final QariItem qariItem; + private String localDirectoryPath = null; + + public DownloadAudioRequest(String baseUrl, SuraAyah verse, + @NonNull QariItem qariItem, String localPath) { + super(baseUrl, verse); + this.qariItem = qariItem; + localDirectoryPath = localPath; + } + + private DownloadAudioRequest(Parcel in) { + super(in); + this.qariItem = in.readParcelable(QariItem.class.getClassLoader()); + this.localDirectoryPath = in.readString(); + } + + @NonNull + public QariItem getQariItem() { + return qariItem; + } + + public String getLocalPath() { + return localDirectoryPath; + } + + @Override + public boolean haveSuraAyah(int sura, int ayah) { + return AudioUtils.haveSuraAyahForQari(localDirectoryPath, sura, ayah); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeParcelable(this.qariItem, 0); + dest.writeString(this.localDirectoryPath); + } + + public static final Creator CREATOR = new Creator() { + public DownloadAudioRequest createFromParcel(Parcel source) { + return new DownloadAudioRequest(source); + } + + public DownloadAudioRequest[] newArray(int size) { + return new DownloadAudioRequest[size]; + } + }; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/PermissionUtil.java b/app/src/main/java/com/quran/labs/androidquran/service/util/PermissionUtil.java new file mode 100644 index 0000000000..e79338b1d8 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/PermissionUtil.java @@ -0,0 +1,26 @@ +package com.quran.labs.androidquran.service.util; + +import com.quran.labs.androidquran.util.QuranSettings; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; + +public class PermissionUtil { + + public static boolean haveWriteExternalStoragePermission(Context context) { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED; + } + + public static boolean canRequestWriteExternalStoragePermission(Activity activity) { + return !QuranSettings.getInstance(activity).didPresentSdcardPermissionsDialog() || + ActivityCompat.shouldShowRequestPermissionRationale(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/QuranDownloadNotifier.java b/app/src/main/java/com/quran/labs/androidquran/service/util/QuranDownloadNotifier.java new file mode 100644 index 0000000000..e9bb84f956 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/QuranDownloadNotifier.java @@ -0,0 +1,306 @@ +package com.quran.labs.androidquran.service.util; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.support.v4.app.NotificationCompat; +import android.support.v4.content.ContextCompat; +import android.support.v4.content.LocalBroadcastManager; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.QuranDataActivity; +import com.quran.labs.androidquran.R; + +public class QuranDownloadNotifier { + // error messages + public static final int ERROR_DISK_SPACE = 1; + public static final int ERROR_PERMISSIONS = 2; + public static final int ERROR_NETWORK = 3; + public static final int ERROR_INVALID_DOWNLOAD = 4; + public static final int ERROR_CANCELLED = 5; + public static final int ERROR_GENERAL = 6; + + // notification ids + private static final int DOWNLOADING_NOTIFICATION = 1; + public static final int DOWNLOADING_COMPLETE_NOTIFICATION = 2; + private static final int DOWNLOADING_ERROR_NOTIFICATION = 3; + private static final int DOWNLOADING_PROCESSING_NOTIFICATION = 4; + + public static class ProgressIntent { + public static final String INTENT_NAME = + "com.quran.labs.androidquran.download.ProgressUpdate"; + public static final String NAME = "notificationTitle"; + public static final String DOWNLOAD_KEY = "downloadKey"; + public static final String DOWNLOAD_TYPE = "downloadType"; + public static final String STATE = "state"; + public static final String PROGRESS = "progress"; + public static final String TOTAL_SIZE = "totalSize"; + public static final String DOWNLOADED_SIZE = "downloadedSize"; + public static final String PROCESSED_FILES = "processedFiles"; + public static final String TOTAL_FILES = "totalFiles"; + public static final String ERROR_CODE = "errorCode"; + public static final String SURA = "sura"; + public static final String AYAH = "ayah"; + + // states for the intent maybe one of these values + public static final String STATE_DOWNLOADING = "downloading"; + public static final String STATE_PROCESSING = "processing"; + public static final String STATE_SUCCESS = "success"; + public static final String STATE_ERROR = "error"; + public static final String STATE_ERROR_WILL_RETRY = "errorWillRetry"; + } + + public static class NotificationDetails { + public String title; + public String key; + public int type; + public int currentFile; + public int totalFiles; + public int sura; + public int ayah; + public boolean sendIndeterminate; + public boolean isGapless; + + public NotificationDetails(String title, String key, int type){ + this.key = key; + this.title = title; + this.type = type; + sendIndeterminate = false; + } + + public void setFileStatus(int current, int total){ + totalFiles = total; + currentFile = current; + } + + public void setIsGapless(boolean isGapless) { + this.isGapless = isGapless; + } + } + + private Context mAppContext; + private NotificationManager mNotificationManager; + private LocalBroadcastManager mBroadcastManager; + private int mNotificationColor; + private int mLastProgress; + private int mLastMaximum; + + public QuranDownloadNotifier(Context context) { + mAppContext = context.getApplicationContext(); + mNotificationManager = (NotificationManager) mAppContext + .getSystemService(Context.NOTIFICATION_SERVICE); + mBroadcastManager = LocalBroadcastManager.getInstance(mAppContext); + mNotificationColor = ContextCompat.getColor(mAppContext, R.color.notification_color); + mLastProgress = -1; + mLastMaximum = -1; + } + + public void resetNotifications() { + // hide any previous errors, canceled, etc + mLastMaximum = -1; + mLastProgress = -1; + mNotificationManager.cancel(DOWNLOADING_ERROR_NOTIFICATION); + mNotificationManager.cancel(DOWNLOADING_COMPLETE_NOTIFICATION); + mNotificationManager.cancel(DOWNLOADING_PROCESSING_NOTIFICATION); + } + + public Intent notifyProgress(NotificationDetails details, + long downloadedSize, long totalSize){ + + int max = 100; + int progress = 0; + + // send indeterminate if total size is 0 or less or the notification + // details says to always send indeterminate (never happens right now, + // so only when the total size is 0 or less). + boolean isIndeterminate = details.sendIndeterminate; + if (!isIndeterminate && totalSize <= 0){ + isIndeterminate = true; + } + + if (!isIndeterminate){ + // calculate percentage based on files downloaded, files left, + // and percentage of this file that is left + double percent = (1.0 * downloadedSize) / (1.0 * totalSize); + if (details.isGapless) { + progress = (int)(percent * 100); + } else { + double percentPerFile = 100.0f / details.totalFiles; + progress = (int)((percentPerFile * (details.currentFile - 1)) + + (percent * percentPerFile)); + } + + if (details.sura > 0 && details.ayah > 0) { + progress = (int) (((float) details.currentFile / (float) details.totalFiles) * 100.0f); + } + } + + showNotification(details.title, + mAppContext.getString(R.string.downloading_title), + DOWNLOADING_NOTIFICATION, true, max, progress, isIndeterminate); + + // send broadcast + Intent progressIntent = new Intent(ProgressIntent.INTENT_NAME); + progressIntent.putExtra(ProgressIntent.NAME, details.title); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_KEY, details.key); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_TYPE, details.type); + progressIntent.putExtra(ProgressIntent.STATE, + ProgressIntent.STATE_DOWNLOADING); + if (details.sura > 0) { + progressIntent.putExtra(ProgressIntent.SURA, details.sura); + progressIntent.putExtra(ProgressIntent.AYAH, details.ayah); + } + + if (!isIndeterminate){ + progressIntent.putExtra(ProgressIntent.DOWNLOADED_SIZE, + downloadedSize); + progressIntent.putExtra(ProgressIntent.TOTAL_SIZE, totalSize); + progressIntent.putExtra(ProgressIntent.PROGRESS, progress); + } + mBroadcastManager.sendBroadcast(progressIntent); + return progressIntent; + } + + public Intent notifyDownloadProcessing( + NotificationDetails details, int done, int total){ + String processingString = + mAppContext.getString(R.string.download_processing); + mNotificationManager.cancel(DOWNLOADING_NOTIFICATION); + showNotification(details.title, processingString, + DOWNLOADING_PROCESSING_NOTIFICATION, true); + + // send broadcast + Intent progressIntent = new Intent(ProgressIntent.INTENT_NAME); + progressIntent.putExtra(ProgressIntent.NAME, details.title); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_KEY, details.key); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_TYPE, details.type); + progressIntent.putExtra(ProgressIntent.STATE, + ProgressIntent.STATE_PROCESSING); + + if (total > 0){ + int progress = (int)((100.0 * done) / (1.0 * total)); + progressIntent.putExtra(ProgressIntent.PROGRESS, progress); + progressIntent.putExtra(ProgressIntent.PROCESSED_FILES, done); + progressIntent.putExtra(ProgressIntent.TOTAL_FILES, total); + } + + mBroadcastManager.sendBroadcast(progressIntent); + return progressIntent; + } + + public Intent notifyDownloadSuccessful(NotificationDetails details){ + String successString = mAppContext.getString(R.string.download_successful); + mNotificationManager.cancel(DOWNLOADING_NOTIFICATION); + mNotificationManager.cancel(DOWNLOADING_PROCESSING_NOTIFICATION); + mNotificationManager.cancel(DOWNLOADING_ERROR_NOTIFICATION); + mLastMaximum = -1; + mLastProgress = -1; + showNotification(details.title, successString, + DOWNLOADING_COMPLETE_NOTIFICATION, false); + return broadcastDownloadSuccessful(details); + } + + public Intent broadcastDownloadSuccessful(NotificationDetails details){ + // send broadcast + Intent progressIntent = new Intent(ProgressIntent.INTENT_NAME); + progressIntent.putExtra(ProgressIntent.NAME, details.title); + progressIntent.putExtra(ProgressIntent.STATE, + ProgressIntent.STATE_SUCCESS); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_KEY, details.key); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_TYPE, details.type); + mBroadcastManager.sendBroadcast(progressIntent); + return progressIntent; + } + + public Intent notifyError(int errorCode, boolean isFatal, + NotificationDetails details){ + int errorId; + switch (errorCode){ + case ERROR_DISK_SPACE: + errorId = R.string.download_error_disk; + break; + case ERROR_NETWORK: + errorId = R.string.download_error_network; + break; + case ERROR_PERMISSIONS: + errorId = R.string.download_error_perms; + break; + case ERROR_INVALID_DOWNLOAD: + errorId = R.string.download_error_invalid_download; + if (!isFatal){ + errorId = R.string.download_error_invalid_download_retry; + } + break; + case ERROR_CANCELLED: + errorId = R.string.notification_download_canceled; + break; + case ERROR_GENERAL: + default: + errorId = R.string.download_error_general; + break; + } + + String errorString = mAppContext.getString(errorId); + mNotificationManager.cancel(DOWNLOADING_NOTIFICATION); + showNotification(details.title, errorString, + DOWNLOADING_ERROR_NOTIFICATION, false); + + String state = isFatal? ProgressIntent.STATE_ERROR : + ProgressIntent.STATE_ERROR_WILL_RETRY; + + // send broadcast + Intent progressIntent = new Intent(ProgressIntent.INTENT_NAME); + progressIntent.putExtra(ProgressIntent.NAME, details.title); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_KEY, details.key); + progressIntent.putExtra(ProgressIntent.DOWNLOAD_TYPE, details.type); + progressIntent.putExtra(ProgressIntent.STATE, state); + progressIntent.putExtra(ProgressIntent.ERROR_CODE, errorCode); + mBroadcastManager.sendBroadcast(progressIntent); + return progressIntent; + } + + private void showNotification(String titleString, + String statusString, int notificationId, boolean isOnGoing) { + showNotification(titleString, statusString, notificationId, + isOnGoing, 0, 0, false); + } + + private void showNotification(String titleString, + String statusString, int notificationId, boolean isOnGoing, + int maximum, int progress, boolean isIndeterminate){ + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(mAppContext); + builder.setSmallIcon(R.drawable.ic_notification) + .setColor(mNotificationColor) + .setAutoCancel(true) + .setOngoing(isOnGoing) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(titleString) + .setContentText(statusString); + + boolean wantProgress = maximum > 0 && maximum >= progress; + if (mLastProgress == progress && mLastMaximum == maximum) { + // don't keep sending repeat notifications + return; + } + mLastProgress = progress; + mLastMaximum = maximum; + + if (wantProgress) { + builder.setProgress(maximum, progress, isIndeterminate); + } + + Intent notificationIntent = new Intent(mAppContext, QuranDataActivity.class); + PendingIntent contentIntent = PendingIntent.getActivity( + mAppContext, 0, notificationIntent, 0); + builder.setContentIntent(contentIntent); + + try { + mNotificationManager.notify(notificationId, builder.build()); + } catch (SecurityException se) { + Crashlytics.logException(se); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/RepeatInfo.java b/app/src/main/java/com/quran/labs/androidquran/service/util/RepeatInfo.java new file mode 100644 index 0000000000..cb50f8a642 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/RepeatInfo.java @@ -0,0 +1,77 @@ +package com.quran.labs.androidquran.service.util; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.quran.labs.androidquran.data.SuraAyah; + +public class RepeatInfo implements Parcelable { + + private int repeatCount; + private int currentAyah; + private int currentSura; + private int currentPlayCount; + + RepeatInfo(int repeatCount) { + this.repeatCount = repeatCount; + } + + private RepeatInfo(Parcel in) { + this.repeatCount = in.readInt(); + this.currentAyah = in.readInt(); + this.currentSura = in.readInt(); + this.currentPlayCount = in.readInt(); + } + + void setCurrentVerse(int sura, int ayah) { + if (sura != currentSura || ayah != currentAyah) { + currentSura = sura; + currentAyah = ayah; + currentPlayCount = 0; + } + } + + public int getRepeatCount() { + return repeatCount; + } + + void setRepeatCount(int repeatCount) { + this.repeatCount = repeatCount; + } + + boolean shouldRepeat() { + return repeatCount == -1 || (currentPlayCount < repeatCount); + } + + void incrementRepeat() { + currentPlayCount++; + } + + SuraAyah getCurrentAyah() { + return new SuraAyah(currentSura, currentAyah); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(this.repeatCount); + dest.writeInt(this.currentAyah); + dest.writeInt(this.currentSura); + dest.writeInt(this.currentPlayCount); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public RepeatInfo createFromParcel(Parcel source) { + return new RepeatInfo(source); + } + + public RepeatInfo[] newArray(int size) { + return new RepeatInfo[size]; + } + }; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/ServiceIntentHelper.java b/app/src/main/java/com/quran/labs/androidquran/service/util/ServiceIntentHelper.java new file mode 100644 index 0000000000..6743ab3a46 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/ServiceIntentHelper.java @@ -0,0 +1,69 @@ +package com.quran.labs.androidquran.service.util; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.service.QuranDownloadService; + +import android.content.Context; +import android.content.Intent; + +public class ServiceIntentHelper { + + public static Intent getDownloadIntent(Context context, String url, + String destination, + String notificationTitle, + String key, int type){ + Intent intent = new Intent(context, QuranDownloadService.class); + intent.putExtra(QuranDownloadService.EXTRA_URL, url); + intent.putExtra(QuranDownloadService.EXTRA_DESTINATION, + destination); + intent.putExtra(QuranDownloadService.EXTRA_NOTIFICATION_NAME, + notificationTitle); + intent.putExtra(QuranDownloadService.EXTRA_DOWNLOAD_KEY, + key); + intent.putExtra(QuranDownloadService.EXTRA_DOWNLOAD_TYPE, + type); + intent.setAction(QuranDownloadService.ACTION_DOWNLOAD_URL); + return intent; + } + + public static int getErrorResourceFromDownloadIntent(Intent intent, + boolean willRetry){ + int errorCode = intent.getIntExtra( + QuranDownloadNotifier.ProgressIntent.ERROR_CODE, 0); + return getErrorResourceFromErrorCode(errorCode, willRetry); + } + + public static int getErrorResourceFromErrorCode(int errorCode, + boolean willRetry){ + int errorId = 0; + + switch (errorCode){ + case QuranDownloadNotifier.ERROR_DISK_SPACE: + errorId = R.string.download_error_disk; + break; + case QuranDownloadNotifier.ERROR_NETWORK: + errorId = R.string.download_error_network; + if (willRetry){ + errorId = R.string.download_error_network_retry; + } + break; + case QuranDownloadNotifier.ERROR_PERMISSIONS: + errorId = R.string.download_error_perms; + break; + case QuranDownloadNotifier.ERROR_INVALID_DOWNLOAD: + errorId = R.string.download_error_invalid_download; + if (willRetry){ + errorId = R.string.download_error_invalid_download_retry; + } + break; + case QuranDownloadNotifier.ERROR_CANCELLED: + errorId = R.string.notification_download_canceled; + break; + case QuranDownloadNotifier.ERROR_GENERAL: + default: + errorId = R.string.download_error_general; + } + + return errorId; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/service/util/StreamingAudioRequest.java b/app/src/main/java/com/quran/labs/androidquran/service/util/StreamingAudioRequest.java new file mode 100644 index 0000000000..1d0e931fa1 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/service/util/StreamingAudioRequest.java @@ -0,0 +1,42 @@ +package com.quran.labs.androidquran.service.util; + +import android.os.Parcel; + +import com.quran.labs.androidquran.data.SuraAyah; + +public class StreamingAudioRequest extends AudioRequest { + + public StreamingAudioRequest(String baseUrl, SuraAyah verse) { + super(baseUrl, verse); + } + + protected StreamingAudioRequest(Parcel in) { + super(in); + } + + public boolean haveSuraAyah(int sura, int ayah) { + // for streaming, we (theoretically) always "have" the sura and ayah + return true; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + } + + public static final Creator CREATOR + = new Creator() { + public StreamingAudioRequest createFromParcel(Parcel source) { + return new StreamingAudioRequest(source); + } + + public StreamingAudioRequest[] newArray(int size) { + return new StreamingAudioRequest[size]; + } + }; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/AudioManagerActivity.java b/app/src/main/java/com/quran/labs/androidquran/ui/AudioManagerActivity.java new file mode 100644 index 0000000000..629d57f5fe --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/AudioManagerActivity.java @@ -0,0 +1,234 @@ +package com.quran.labs.androidquran.ui; + +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QariItem; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.service.QuranDownloadService; +import com.quran.labs.androidquran.service.util.DefaultDownloadReceiver; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.ServiceIntentHelper; +import com.quran.labs.androidquran.util.AudioManagerUtils; +import com.quran.labs.androidquran.util.AudioUtils; +import com.quran.labs.androidquran.util.QariDownloadInfo; +import com.quran.labs.androidquran.util.QuranFileUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.observers.DisposableSingleObserver; + + +public class AudioManagerActivity extends QuranActionBarActivity + implements DefaultDownloadReceiver.SimpleDownloadListener { + private static final String AUDIO_DOWNLOAD_KEY = "AudioManager.DownloadKey"; + + private ProgressBar progressBar; + private Disposable disposable; + private ShuyookhAdapter shuyookhAdapter; + private RecyclerView recyclerView; + private DefaultDownloadReceiver downloadReceiver; + private String basePath; + private List qariItems; + + @Override + protected void onCreate(Bundle savedInstanceState) { + QuranApplication quranApp = (QuranApplication) getApplication(); + quranApp.refreshLocale(this, false); + + super.onCreate(savedInstanceState); + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setTitle(R.string.audio_manager); + ab.setDisplayHomeAsUpEnabled(true); + } + + setContentView(R.layout.audio_manager); + + recyclerView = (RecyclerView) findViewById(R.id.recycler_view); + recyclerView.setHasFixedSize(true); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + recyclerView.setItemAnimator(new DefaultItemAnimator()); + + qariItems = AudioUtils.getQariList(this); + shuyookhAdapter = new ShuyookhAdapter(qariItems); + recyclerView.setAdapter(shuyookhAdapter); + + progressBar = (ProgressBar) findViewById(R.id.progress); + + basePath = QuranFileUtils.getQuranAudioDirectory(this); + getShuyookhData(); + } + + private void getShuyookhData() { + if (disposable != null) { + disposable.dispose(); + } + disposable = AudioManagerUtils.shuyookhDownloadObservable(basePath, qariItems) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(mOnDownloadInfo); + } + + @Override + protected void onResume() { + super.onResume(); + downloadReceiver = new DefaultDownloadReceiver(this, + QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + downloadReceiver.setCanCancelDownload(true); + LocalBroadcastManager.getInstance(this).registerReceiver(downloadReceiver, + new IntentFilter(QuranDownloadNotifier.ProgressIntent.INTENT_NAME)); + downloadReceiver.setListener(this); + } + + @Override + protected void onPause() { + downloadReceiver.setListener(null); + LocalBroadcastManager.getInstance(this).unregisterReceiver(downloadReceiver); + super.onPause(); + } + + @Override + protected void onDestroy() { + disposable.dispose(); + super.onDestroy(); + } + + private DisposableSingleObserver> mOnDownloadInfo = + new DisposableSingleObserver>() { + @Override + public void onSuccess(List downloadInfo) { + progressBar.setVisibility(View.GONE); + shuyookhAdapter.setDownloadInfo(downloadInfo); + shuyookhAdapter.notifyDataSetChanged(); + } + + @Override + public void onError(Throwable e) { + } + }; + + private View.OnClickListener mOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + int position = recyclerView.getChildAdapterPosition(v); + if (position != RecyclerView.NO_POSITION) { + QariDownloadInfo info = shuyookhAdapter.getSheikhInfoForPosition(position); + if (info.downloadedSuras.size() != 114) { + download(qariItems.get(position)); + } + } + } + }; + + private void download(QariItem qariItem) { + String baseUri = basePath + qariItem.getPath(); + boolean isGapless = qariItem.isGapless(); + + String sheikhName = qariItem.getName(); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, + AudioUtils.getQariUrl(qariItem), + baseUri, sheikhName, AUDIO_DOWNLOAD_KEY, QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + intent.putExtra(QuranDownloadService.EXTRA_START_VERSE, new SuraAyah(1, 1)); + intent.putExtra(QuranDownloadService.EXTRA_END_VERSE, new SuraAyah(114, 6)); + intent.putExtra(QuranDownloadService.EXTRA_IS_GAPLESS, isGapless); + startService(intent); + + AudioManagerUtils.clearCacheKeyForSheikh(qariItem); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void handleDownloadSuccess() { + getShuyookhData(); + } + + @Override + public void handleDownloadFailure(int errId) { + getShuyookhData(); + } + + private class ShuyookhAdapter extends RecyclerView.Adapter { + private final LayoutInflater mInflater; + private final List mQariItems; + private final Map mDownloadInfoMap; + + ShuyookhAdapter(List items) { + mQariItems = items; + mDownloadInfoMap = new HashMap<>(); + mInflater = LayoutInflater.from(AudioManagerActivity.this); + } + + void setDownloadInfo(List downloadInfo) { + for (QariDownloadInfo info : downloadInfo) { + mDownloadInfoMap.put(info.qariItem, info); + } + } + + @Override + public SheikhViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new SheikhViewHolder(mInflater.inflate(R.layout.audio_manager_row, parent, false)); + } + + @Override + public void onBindViewHolder(SheikhViewHolder holder, int position) { + holder.name.setText(mQariItems.get(position).getName()); + + QariDownloadInfo info = getSheikhInfoForPosition(position); + int fullyDownloaded = info.downloadedSuras.size(); + holder.quantity.setText( + getResources().getQuantityString(R.plurals.files_downloaded, + fullyDownloaded, fullyDownloaded)); + } + + QariDownloadInfo getSheikhInfoForPosition(int position) { + return mDownloadInfoMap.get(mQariItems.get(position)); + } + + @Override + public int getItemCount() { + return mDownloadInfoMap.size() == 0 ? 0 : mQariItems.size(); + } + } + + private class SheikhViewHolder extends RecyclerView.ViewHolder { + public final TextView name; + public final TextView quantity; + public final ImageView image; + + SheikhViewHolder(View itemView) { + super(itemView); + name = (TextView) itemView.findViewById(R.id.name); + quantity = (TextView) itemView.findViewById(R.id.quantity); + + image = (ImageView) itemView.findViewById(R.id.image); + itemView.setOnClickListener(mOnClickListener); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java b/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java new file mode 100644 index 0000000000..ed5f59b451 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java @@ -0,0 +1,2138 @@ +package com.quran.labs.androidquran.ui; + +import android.annotation.TargetApi; +import android.app.ProgressDialog; +import android.app.SearchManager; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.util.Pair; +import android.support.v4.view.MenuItemCompat; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.ViewPager; +import android.support.v4.view.ViewPager.OnPageChangeListener; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.SearchView; +import android.support.v7.widget.Toolbar; +import android.text.TextUtils; +import android.util.SparseBooleanArray; +import android.view.HapticFeedbackConstants; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.LayoutInflater; +import android.widget.Toast; + +import com.quran.labs.androidquran.HelpActivity; +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.QuranPreferenceActivity; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.SearchActivity; +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.common.QariItem; +import com.quran.labs.androidquran.component.activity.PagerActivityComponent; +import com.quran.labs.androidquran.data.AyahInfoDatabaseProvider; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.data.QuranDataProvider; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.database.TranslationsDBAdapter; +import com.quran.labs.androidquran.model.bookmark.BookmarkModel; +import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils; +import com.quran.labs.androidquran.module.activity.PagerActivityModule; +import com.quran.labs.androidquran.presenter.bookmark.RecentPagePresenter; +import com.quran.labs.androidquran.service.AudioService; +import com.quran.labs.androidquran.service.QuranDownloadService; +import com.quran.labs.androidquran.service.util.AudioRequest; +import com.quran.labs.androidquran.service.util.DefaultDownloadReceiver; +import com.quran.labs.androidquran.service.util.DownloadAudioRequest; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.ServiceIntentHelper; +import com.quran.labs.androidquran.service.util.StreamingAudioRequest; +import com.quran.labs.androidquran.ui.fragment.AddTagDialog; +import com.quran.labs.androidquran.ui.fragment.AyahActionFragment; +import com.quran.labs.androidquran.ui.fragment.JumpFragment; +import com.quran.labs.androidquran.ui.fragment.TabletFragment; +import com.quran.labs.androidquran.ui.fragment.TagBookmarkDialog; +import com.quran.labs.androidquran.ui.fragment.TranslationFragment; +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; +import com.quran.labs.androidquran.ui.helpers.AyahTracker; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.ui.helpers.QuranDisplayHelper; +import com.quran.labs.androidquran.ui.helpers.QuranPage; +import com.quran.labs.androidquran.ui.helpers.QuranPageAdapter; +import com.quran.labs.androidquran.ui.helpers.QuranPageWorker; +import com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter; +import com.quran.labs.androidquran.ui.util.TranslationsSpinnerAdapter; +import com.quran.labs.androidquran.util.AudioUtils; +import com.quran.labs.androidquran.util.QuranAppUtils; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.util.ShareUtil; +import com.quran.labs.androidquran.util.TranslationUtils; +import com.quran.labs.androidquran.widgets.AudioStatusBar; +import com.quran.labs.androidquran.widgets.AyahToolBar; +import com.quran.labs.androidquran.widgets.IconPageIndicator; +import com.quran.labs.androidquran.widgets.QuranSpinner; +import com.quran.labs.androidquran.widgets.SlidingUpPanelLayout; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.observers.DisposableObserver; +import io.reactivex.observers.DisposableSingleObserver; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST; +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST_DUAL; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.AUDIO_PAGE; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.PAGES; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.TAG_PAGE; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.TRANSLATION_PAGE; +import static com.quran.labs.androidquran.widgets.AyahToolBar.AyahToolBarPosition; + +public class PagerActivity extends QuranActionBarActivity implements + AudioStatusBar.AudioBarListener, + DefaultDownloadReceiver.DownloadListener, + TagBookmarkDialog.OnBookmarkTagsUpdateListener, + AyahSelectedListener { + private static final String AUDIO_DOWNLOAD_KEY = "AUDIO_DOWNLOAD_KEY"; + private static final String LAST_AUDIO_DL_REQUEST = "LAST_AUDIO_DL_REQUEST"; + private static final String LAST_READ_PAGE = "LAST_READ_PAGE"; + private static final String LAST_READING_MODE_IS_TRANSLATION = + "LAST_READING_MODE_IS_TRANSLATION"; + private static final String LAST_ACTIONBAR_STATE = "LAST_ACTIONBAR_STATE"; + private static final String LAST_AUDIO_REQUEST = "LAST_AUDIO_REQUEST"; + private static final String LAST_START_POINT = "LAST_START_POINT"; + private static final String LAST_ENDING_POINT = "LAST_ENDING_POINT"; + + public static final String EXTRA_JUMP_TO_TRANSLATION = "jumpToTranslation"; + public static final String EXTRA_HIGHLIGHT_SURA = "highlightSura"; + public static final String EXTRA_HIGHLIGHT_AYAH = "highlightAyah"; + public static final String LAST_WAS_DUAL_PAGES = "wasDualPages"; + + private static final long DEFAULT_HIDE_AFTER_TIME = 2000; + + private long lastPopupTime = 0; + private boolean isActionBarHidden = true; + private AudioStatusBar audioStatusBar = null; + private ViewPager viewPager = null; + private QuranPageAdapter pagerAdapter = null; + private boolean shouldReconnect = false; + private SparseBooleanArray bookmarksCache = null; + private DownloadAudioRequest lastAudioDownloadRequest = null; + private boolean showingTranslation = false; + private int highlightedSura = -1; + private int highlightedAyah = -1; + private int ayahToolBarTotalHeight; + private boolean shouldOverridePlaying = false; + private DefaultDownloadReceiver downloadReceiver; + private boolean needsPermissionToDownloadOver3g = true; + private AlertDialog promptDialog = null; + private AyahToolBar ayahToolBar; + private AyahToolBarPosition ayahToolBarPos; + private AudioRequest lastAudioRequest; + private boolean isDualPages = false; + private boolean isLandscape; + private boolean isImmersiveInPortrait; + private Integer lastPlayingSura; + private Integer lastPlayingAyah; + private View toolBarArea; + private boolean promptedForExtraDownload; + private QuranSpinner translationsSpinner; + private ProgressDialog progressDialog; + private ViewGroup.MarginLayoutParams audioBarParams; + private boolean isInMultiWindowMode; + + private String[] translationItems; + private List translations; + private TranslationsSpinnerAdapter translationsSpinnerAdapter; + + public static final int MSG_HIDE_ACTIONBAR = 1; + + // AYAH ACTION PANEL STUFF + // Max height of sliding panel (% of screen) + private static final float PANEL_MAX_HEIGHT = 0.6f; + private SlidingUpPanelLayout slidingPanel; + private ViewPager slidingPager; + private SlidingPagerAdapter slidingPagerAdapter; + private boolean isInAyahMode; + private SuraAyah start; + private SuraAyah end; + + private PagerActivityComponent pagerActivityComponent; + + @Inject QuranPageWorker quranPageWorker; + @Inject BookmarkModel bookmarkModel; + @Inject RecentPagePresenter recentPagePresenter; + @Inject AyahInfoDatabaseProvider ayahInfoDatabaseProvider; + @Inject QuranSettings quranSettings; + @Inject QuranScreenInfo quranScreenInfo; + @Inject ArabicDatabaseUtils arabicDatabaseUtils; + @Inject TranslationsDBAdapter translationsDBAdapter; + + private CompositeDisposable compositeDisposable; + + private final PagerHandler handler = new PagerHandler(this); + + private static class PagerHandler extends Handler { + private final WeakReference activity; + + PagerHandler(PagerActivity activity) { + this.activity = new WeakReference<>(activity); + } + + @Override + public void handleMessage(Message msg) { + PagerActivity activity = this.activity.get(); + if (activity != null) { + if (msg.what == MSG_HIDE_ACTIONBAR) { + activity.toggleActionBarVisibility(false); + } else { + super.handleMessage(msg); + } + } + } + } + + @Override + public void onCreate(Bundle savedInstanceState) { + QuranApplication quranApp = (QuranApplication) getApplication(); + quranApp.refreshLocale(this, false); + super.onCreate(savedInstanceState); + + // field injection + getPagerActivityComponent().inject(this); + + bookmarksCache = new SparseBooleanArray(); + + boolean shouldAdjustPageNumber = false; + isDualPages = QuranUtils.isDualPages(this, quranScreenInfo); + + // remove the window background to avoid overdraw. note that, per Romain's blog, this is + // acceptable (as long as we don't set the background color to null in the theme, since + // that is used to generate preview windows). + getWindow().setBackgroundDrawable(null); + + int page = -1; + isActionBarHidden = true; + if (savedInstanceState != null) { + Timber.d("non-null saved instance state!"); + DownloadAudioRequest lastAudioRequest = + savedInstanceState.getParcelable(LAST_AUDIO_DL_REQUEST); + if (lastAudioRequest != null) { + Timber.d("restoring request from saved instance!"); + lastAudioDownloadRequest = lastAudioRequest; + } + page = savedInstanceState.getInt(LAST_READ_PAGE, -1); + if (page != -1) { + page = PAGES_LAST - page; + } + showingTranslation = savedInstanceState + .getBoolean(LAST_READING_MODE_IS_TRANSLATION, false); + if (savedInstanceState.containsKey(LAST_ACTIONBAR_STATE)) { + isActionBarHidden = !savedInstanceState + .getBoolean(LAST_ACTIONBAR_STATE); + } + boolean lastWasDualPages = savedInstanceState.getBoolean(LAST_WAS_DUAL_PAGES, isDualPages); + shouldAdjustPageNumber = (lastWasDualPages != isDualPages); + + start = savedInstanceState.getParcelable(LAST_START_POINT); + end = savedInstanceState.getParcelable(LAST_ENDING_POINT); + this.lastAudioRequest = savedInstanceState.getParcelable(LAST_AUDIO_REQUEST); + } else { + Intent intent = getIntent(); + Bundle extras = intent.getExtras(); + if (extras != null) { + page = PAGES_LAST - extras.getInt("page", Constants.PAGES_FIRST); + showingTranslation = extras.getBoolean(EXTRA_JUMP_TO_TRANSLATION, showingTranslation); + highlightedSura = extras.getInt(EXTRA_HIGHLIGHT_SURA, -1); + highlightedAyah = extras.getInt(EXTRA_HIGHLIGHT_AYAH, -1); + } + } + + compositeDisposable = new CompositeDisposable(); + + // subscribe to changes in bookmarks + compositeDisposable.add( + bookmarkModel.bookmarksObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignore -> { + onBookmarksChanged(); + })); + + final Resources resources = getResources(); + isImmersiveInPortrait = resources.getBoolean(R.bool.immersive_in_portrait); + isLandscape = resources.getConfiguration().orientation == + Configuration.ORIENTATION_LANDSCAPE; + ayahToolBarTotalHeight = resources + .getDimensionPixelSize(R.dimen.toolbar_total_height); + setContentView(R.layout.quran_page_activity_slider); + audioStatusBar = (AudioStatusBar) findViewById(R.id.audio_area); + audioStatusBar.setAudioBarListener(this); + audioBarParams = (ViewGroup.MarginLayoutParams) audioStatusBar.getLayoutParams(); + + toolBarArea = findViewById(R.id.toolbar_area); + translationsSpinner = (QuranSpinner) findViewById(R.id.spinner); + + // this is the colored view behind the status bar on kitkat and above + final View statusBarBackground = findViewById(R.id.status_bg); + statusBarBackground.getLayoutParams().height = getStatusBarHeight(); + + final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + if (quranSettings.isArabicNames() || QuranUtils.isRtl()) { + // remove when we remove LTR from quran_page_activity's root + ViewCompat.setLayoutDirection(toolbar, ViewCompat.LAYOUT_DIRECTION_RTL); + } + setSupportActionBar(toolbar); + + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayShowHomeEnabled(true); + ab.setDisplayHomeAsUpEnabled(true); + } + + initAyahActionPanel(); + + if (showingTranslation && translationItems != null) { + updateActionBarSpinner(); + } else { + updateActionBarTitle(PAGES_LAST - page); + } + + lastPopupTime = System.currentTimeMillis(); + pagerAdapter = new QuranPageAdapter( + getSupportFragmentManager(), isDualPages, showingTranslation); + ayahToolBar = (AyahToolBar) findViewById(R.id.ayah_toolbar); + viewPager = (ViewPager) findViewById(R.id.quran_pager); + viewPager.setAdapter(pagerAdapter); + + ayahToolBar.setOnItemSelectedListener(new AyahMenuItemSelectionHandler()); + viewPager.addOnPageChangeListener(new OnPageChangeListener() { + + @Override + public void onPageScrollStateChanged(int state) { + } + + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + if (ayahToolBar.isShowing() && ayahToolBarPos != null) { + int barPos = QuranInfo.getPosFromPage(start.getPage(), isDualPages); + if (position == barPos) { + // Swiping to next ViewPager page (i.e. prev quran page) + ayahToolBarPos.xScroll = 0 - positionOffsetPixels; + } else if (position == barPos - 1) { + // Swiping to prev ViewPager page (i.e. next quran page) + ayahToolBarPos.xScroll = viewPager.getWidth() - positionOffsetPixels; + } else { + // Totally off screen, should hide toolbar + ayahToolBar.setVisibility(View.GONE); + return; + } + ayahToolBar.updatePosition(ayahToolBarPos); + // If the toolbar is not showing, show it + if (ayahToolBar.getVisibility() != View.VISIBLE) { + ayahToolBar.setVisibility(View.VISIBLE); + } + } + } + + @Override + public void onPageSelected(int position) { + Timber.d("onPageSelected(): %d", position); + final int page = QuranInfo.getPageFromPos(position, isDualPages); + if (quranSettings.shouldDisplayMarkerPopup()) { + lastPopupTime = QuranDisplayHelper.displayMarkerPopup( + PagerActivity.this, page, lastPopupTime); + if (isDualPages) { + lastPopupTime = QuranDisplayHelper.displayMarkerPopup( + PagerActivity.this, page - 1, lastPopupTime); + } + } + + if (!showingTranslation) { + updateActionBarTitle(page); + } else { + refreshActionBarSpinner(); + } + + if (bookmarksCache.indexOfKey(page) < 0) { + if (isDualPages) { + if (bookmarksCache.indexOfKey(page - 1) < 0) { + checkIfPageIsBookmarked(page - 1, page); + } + } else { + // we don't have the key + checkIfPageIsBookmarked(page); + } + } + + // If we're more than 1 page away from ayah selection end ayah mode + if (isInAyahMode) { + int ayahPos = QuranInfo.getPosFromPage(start.getPage(), isDualPages); + if (Math.abs(ayahPos - position) > 1) { + endAyahMode(); + } + } + } + }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + setUiVisibilityListener(); + audioStatusBar.setVisibility(View.VISIBLE); + } + toggleActionBarVisibility(true); + + if (shouldAdjustPageNumber) { + // when going from two page per screen to one or vice versa, we adjust the page number, + // such that the first page is always selected. + int curPage = PAGES_LAST - page; + if (isDualPages) { + if (curPage % 2 != 0) { + curPage++; + } + curPage = PAGES_LAST_DUAL - (curPage / 2); + } else { + if (curPage % 2 == 0) { + curPage--; + } + curPage = PAGES_LAST - curPage; + } + page = curPage; + } else if (isDualPages) { + page = page / 2; + } + + viewPager.setCurrentItem(page); + + // just got created, need to reconnect to service + shouldReconnect = true; + + // enforce orientation lock + if (quranSettings.isLockOrientation()) { + int current = getResources().getConfiguration().orientation; + if (quranSettings.isLandscapeOrientation()) { + if (current == Configuration.ORIENTATION_PORTRAIT) { + setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + return; + } + } else if (current == Configuration.ORIENTATION_LANDSCAPE) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + return; + } + } + + LocalBroadcastManager.getInstance(this).registerReceiver( + audioReceiver, + new IntentFilter(AudioService.AudioUpdateIntent.INTENT_NAME)); + + downloadReceiver = new DefaultDownloadReceiver(this, + QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + String action = QuranDownloadNotifier.ProgressIntent.INTENT_NAME; + LocalBroadcastManager.getInstance(this).registerReceiver( + downloadReceiver, + new IntentFilter(action)); + downloadReceiver.setListener(this); + } + + public Observable getViewPagerObservable() { + return Observable.create(e -> { + final OnPageChangeListener pageChangedListener = + new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + e.onNext(QuranInfo.getPageFromPos(position, isDualPages)); + } + }; + + viewPager.addOnPageChangeListener(pageChangedListener); + e.onNext(getCurrentPage()); + + e.setCancellable(() -> viewPager.removeOnPageChangeListener(pageChangedListener)); + }); + } + + private int getStatusBarHeight() { + // thanks to https://github.com/jgilfelt/SystemBarTint for this + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + final Resources resources = getResources(); + final int resId = resources.getIdentifier( + "status_bar_height", "dimen", "android"); + if (resId > 0) { + return resources.getDimensionPixelSize(resId); + } + } + return 0; + } + + private void initAyahActionPanel() { + slidingPanel = (SlidingUpPanelLayout) findViewById(R.id.sliding_panel); + final ViewGroup slidingLayout = + (ViewGroup) slidingPanel.findViewById(R.id.sliding_layout); + slidingPager = (ViewPager) slidingPanel + .findViewById(R.id.sliding_layout_pager); + final IconPageIndicator slidingPageIndicator = + (IconPageIndicator) slidingPanel + .findViewById(R.id.sliding_pager_indicator); + + // Find close button and set listener + final View closeButton = slidingPanel + .findViewById(R.id.sliding_menu_close); + closeButton.setOnClickListener(v -> endAyahMode()); + + // Create and set fragment pager adapter + slidingPagerAdapter = new SlidingPagerAdapter(getSupportFragmentManager(), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && + (quranSettings.isArabicNames() || QuranUtils.isRtl())); + slidingPager.setAdapter(slidingPagerAdapter); + + // Attach the view pager to the action bar + slidingPageIndicator.setViewPager(slidingPager); + + // Set sliding layout parameters + int displayHeight = getResources().getDisplayMetrics().heightPixels; + slidingLayout.getLayoutParams().height = + (int) (displayHeight * PANEL_MAX_HEIGHT); + slidingPanel.setEnableDragViewTouchEvents(true); + slidingPanel.setPanelSlideListener(new SlidingPanelListener()); + slidingLayout.setVisibility(View.GONE); + + // When clicking any menu items, expand the panel + slidingPageIndicator.setOnClickListener(v -> { + if (!slidingPanel.isExpanded()) { + slidingPanel.expandPane(); + } + }); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + handler.sendEmptyMessageDelayed(MSG_HIDE_ACTIONBAR, DEFAULT_HIDE_AFTER_TIME); + } else { + handler.removeMessages(MSG_HIDE_ACTIONBAR); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private void setUiVisibility(boolean isVisible) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && + (isLandscape || isImmersiveInPortrait)){ + setUiVisibilityKitKat(isVisible); + if (isInMultiWindowMode) { + animateToolBar(isVisible); + } + return; + } + + int flags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; + if (!isVisible) { + flags |= View.SYSTEM_UI_FLAG_LOW_PROFILE | View.SYSTEM_UI_FLAG_FULLSCREEN; + } + viewPager.setSystemUiVisibility(flags); + if (isInMultiWindowMode) { + animateToolBar(isVisible); + } + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + private void setUiVisibilityKitKat(boolean isVisible) { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + if (!isVisible) { + flags |= View.SYSTEM_UI_FLAG_LOW_PROFILE + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE; + } + viewPager.setSystemUiVisibility(flags); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private void setUiVisibilityListener(){ + viewPager.setOnSystemUiVisibilityChangeListener( + flags -> { + boolean visible = (flags & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0; + animateToolBar(visible); + }); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private void clearUiVisibilityListener(){ + viewPager.setOnSystemUiVisibilityChangeListener(null); + } + + private void animateToolBar(boolean visible) { + isActionBarHidden = !visible; + if (visible) { + audioStatusBar.updateSelectedItem(); + } + + // animate toolbar + toolBarArea.animate() + .translationY(visible ? 0 : -toolBarArea.getHeight()) + .setDuration(250) + .start(); + + /* the bottom margin on the audio bar is not part of its height, and so we have to + * take it into account when animating the audio bar off the screen. */ + final int bottomMargin = audioBarParams.bottomMargin; + + // and audio bar + audioStatusBar.animate() + .translationY(visible ? 0 : audioStatusBar.getHeight() + bottomMargin) + .setDuration(250) + .start(); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + boolean navigate = audioStatusBar.getCurrentMode() != + AudioStatusBar.PLAYING_MODE + && PreferenceManager.getDefaultSharedPreferences(this). + getBoolean(Constants.PREF_USE_VOLUME_KEY_NAV, false); + if (navigate && keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { + viewPager.setCurrentItem(viewPager.getCurrentItem() - 1); + return true; + } else if (navigate && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + viewPager.setCurrentItem(viewPager.getCurrentItem() + 1); + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { + return ((keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP) && + audioStatusBar.getCurrentMode() != + AudioStatusBar.PLAYING_MODE && + PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(Constants.PREF_USE_VOLUME_KEY_NAV, false)) + || super.onKeyUp(keyCode, event); + } + + @Override + public void onResume() { + super.onResume(); + + recentPagePresenter.bind(this); + isInMultiWindowMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode(); + + // read the list of translations + requestTranslationsList(); + + if (shouldReconnect) { + startService(AudioUtils.getAudioIntent(this, AudioService.ACTION_CONNECT)); + shouldReconnect = false; + } + + if (highlightedSura > 0 && highlightedAyah > 0) { + handler.postDelayed(() -> + highlightAyah(highlightedSura, highlightedAyah, false, HighlightType.SELECTION), 750); + } + } + + @NonNull + public PagerActivityComponent getPagerActivityComponent() { + // a fragment may call this before Activity's onCreate, so cache and reuse. + if (pagerActivityComponent == null) { + pagerActivityComponent = ((QuranApplication) getApplication()) + .getApplicationComponent() + .pagerActivityComponentBuilder() + .withPagerActivityModule(new PagerActivityModule(this)) + .build(); + } + return pagerActivityComponent; + } + + public void showGetRequiredFilesDialog() { + if (promptDialog != null) { + return; + } + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.download_extra_data) + .setPositiveButton(R.string.downloadPrompt_ok, + (dialog, option) -> { + downloadRequiredFiles(); + dialog.dismiss(); + promptDialog = null; + }) + .setNegativeButton(R.string.downloadPrompt_no, + (dialog, option) -> { + dialog.dismiss(); + promptDialog = null; + }); + promptDialog = builder.create(); + promptDialog.show(); + } + + private void downloadRequiredFiles() { + int downloadType = QuranDownloadService.DOWNLOAD_TYPE_AUDIO; + if (audioStatusBar.getCurrentMode() == AudioStatusBar.STOPPED_MODE) { + // if we're stopped, use audio download bar as our progress bar + audioStatusBar.switchMode(AudioStatusBar.DOWNLOADING_MODE); + if (isActionBarHidden) { + toggleActionBar(); + } + } else { + // if audio is playing, let's not disrupt it - do this using a + // different type so the broadcast receiver ignores it. + downloadType = QuranDownloadService.DOWNLOAD_TYPE_ARABIC_SEARCH_DB; + } + + boolean haveDownload = false; + if (!QuranFileUtils.haveAyaPositionFile(this)) { + String url = QuranFileUtils.getAyaPositionFileUrl(); + if (QuranUtils.isDualPages(this, quranScreenInfo)) { + url = QuranFileUtils.getAyaPositionFileUrl( + quranScreenInfo.getTabletWidthParam()); + } + String destination = QuranFileUtils.getQuranAyahDatabaseDirectory(this); + // start the download + String notificationTitle = getString(R.string.highlighting_database); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + destination, notificationTitle, AUDIO_DOWNLOAD_KEY, + downloadType); + startService(intent); + + haveDownload = true; + } + + if (!QuranFileUtils.hasArabicSearchDatabase(this)) { + String url = QuranFileUtils.getArabicSearchDatabaseUrl(); + + // show "downloading required files" unless we already showed that for + // highlighting database, in which case show "downloading search data" + String notificationTitle = getString(R.string.highlighting_database); + if (haveDownload) { + notificationTitle = getString(R.string.search_data); + } + + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + QuranFileUtils.getQuranDatabaseDirectory(this), notificationTitle, + AUDIO_DOWNLOAD_KEY, downloadType); + intent.putExtra(QuranDownloadService.EXTRA_OUTPUT_FILE_NAME, + QuranDataProvider.QURAN_ARABIC_DATABASE); + startService(intent); + } + + if (downloadType != QuranDownloadService.DOWNLOAD_TYPE_AUDIO) { + // if audio is playing, just show a status notification + Toast.makeText(this, R.string.downloading_title, + Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onNewIntent(Intent intent) { + if (intent == null) { + return; + } + + recentPagePresenter.onJump(); + Bundle extras = intent.getExtras(); + if (extras != null) { + int page = PAGES_LAST - extras.getInt("page", Constants.PAGES_FIRST); + updateActionBarTitle(PAGES_LAST - page); + + boolean currentValue = showingTranslation; + showingTranslation = extras.getBoolean(EXTRA_JUMP_TO_TRANSLATION, showingTranslation); + highlightedSura = extras.getInt(EXTRA_HIGHLIGHT_SURA, -1); + highlightedAyah = extras.getInt(EXTRA_HIGHLIGHT_AYAH, -1); + + if (showingTranslation != currentValue) { + if (showingTranslation) { + pagerAdapter.setTranslationMode(); + } else { + pagerAdapter.setQuranMode(); + } + + supportInvalidateOptionsMenu(); + } + + if (highlightedAyah > 0 && highlightedSura > 0) { + // this will jump to the right page automagically + highlightAyah(highlightedSura, highlightedAyah, true, HighlightType.SELECTION); + } else { + if (isDualPages) { + page = page / 2; + } + viewPager.setCurrentItem(page); + } + + setIntent(intent); + } + } + + public void jumpTo(int page) { + Intent i = new Intent(this, PagerActivity.class); + i.putExtra("page", page); + onNewIntent(i); + } + + public void jumpToAndHighlight(int page, int sura, int ayah) { + Intent i = new Intent(this, PagerActivity.class); + i.putExtra("page", page); + i.putExtra(EXTRA_HIGHLIGHT_SURA, sura); + i.putExtra(EXTRA_HIGHLIGHT_AYAH, ayah); + onNewIntent(i); + } + + @Override + public void onPause() { + if (promptDialog != null) { + promptDialog.dismiss(); + promptDialog = null; + } + recentPagePresenter.unbind(this); + quranSettings.setWasShowingTranslation(pagerAdapter.getIsShowingTranslation()); + super.onPause(); + } + + @Override + protected void onDestroy() { + Timber.d("onDestroy()"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + clearUiVisibilityListener(); + } + + // remove broadcast receivers + LocalBroadcastManager.getInstance(this).unregisterReceiver(audioReceiver); + if (downloadReceiver != null) { + downloadReceiver.setListener(null); + LocalBroadcastManager.getInstance(this) + .unregisterReceiver(downloadReceiver); + downloadReceiver = null; + } + + compositeDisposable.dispose(); + handler.removeCallbacksAndMessages(null); + dismissProgressDialog(); + super.onDestroy(); + } + + @Override + public void onSaveInstanceState(Bundle state) { + if (lastAudioDownloadRequest != null) { + state.putParcelable(LAST_AUDIO_DL_REQUEST, lastAudioDownloadRequest); + } + int lastPage = QuranInfo.getPageFromPos(viewPager.getCurrentItem(), isDualPages); + state.putInt(LAST_READ_PAGE, lastPage); + state.putBoolean(LAST_READING_MODE_IS_TRANSLATION, showingTranslation); + state.putBoolean(LAST_ACTIONBAR_STATE, isActionBarHidden); + state.putBoolean(LAST_WAS_DUAL_PAGES, isDualPages); + if (start != null && end != null) { + state.putParcelable(LAST_START_POINT, start); + state.putParcelable(LAST_ENDING_POINT, end); + } + if (lastAudioRequest != null) { + state.putParcelable(LAST_AUDIO_REQUEST, lastAudioRequest); + } + super.onSaveInstanceState(state); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.quran_menu, menu); + final MenuItem item = menu.findItem(R.id.search); + final SearchView searchView = (SearchView) MenuItemCompat.getActionView(item); + final SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); + searchView.setQueryHint(getString(R.string.search_hint)); + searchView.setSearchableInfo(searchManager.getSearchableInfo( + new ComponentName(this, SearchActivity.class))); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + MenuItem item = menu.findItem(R.id.favorite_item); + if (item != null) { + int page = QuranInfo.getPageFromPos(viewPager.getCurrentItem(), isDualPages); + + boolean bookmarked = false; + if (bookmarksCache.indexOfKey(page) >= 0) { + bookmarked = bookmarksCache.get(page); + } + + if (!bookmarked && isDualPages && + bookmarksCache.indexOfKey(page - 1) >= 0) { + bookmarked = bookmarksCache.get(page - 1); + } + + item.setIcon(bookmarked ? R.drawable.ic_favorite : R.drawable.ic_not_favorite); + } + + MenuItem quran = menu.findItem(R.id.goto_quran); + MenuItem translation = menu.findItem(R.id.goto_translation); + if (quran != null && translation != null) { + if (!showingTranslation) { + quran.setVisible(false); + translation.setVisible(true); + } else { + quran.setVisible(true); + translation.setVisible(false); + } + } + + MenuItem nightMode = menu.findItem(R.id.night_mode); + if (nightMode != null) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + final boolean isNightMode = prefs.getBoolean(Constants.PREF_NIGHT_MODE, false); + nightMode.setChecked(isNightMode); + nightMode.setIcon(isNightMode ? R.drawable.ic_night_mode : R.drawable.ic_day_mode); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.favorite_item) { + int page = getCurrentPage(); + toggleBookmark(null, null, page); + return true; + } else if (itemId == R.id.goto_quran) { + switchToQuran(); + return true; + } else if (itemId == R.id.goto_translation) { + switchToTranslation(); + return true; + } else if (itemId == R.id.night_mode) { + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(this); + SharedPreferences.Editor prefsEditor = prefs.edit(); + final boolean isNightMode = !item.isChecked(); + prefsEditor.putBoolean(Constants.PREF_NIGHT_MODE, isNightMode).apply(); + item.setIcon(isNightMode ? + R.drawable.ic_night_mode : R.drawable.ic_day_mode); + item.setChecked(isNightMode); + refreshQuranPages(); + return true; + } else if (itemId == R.id.settings) { + Intent i = new Intent(this, QuranPreferenceActivity.class); + startActivity(i); + return true; + } else if (itemId == R.id.help) { + Intent i = new Intent(this, HelpActivity.class); + startActivity(i); + return true; + } else if (itemId == android.R.id.home) { + finish(); + return true; + } else if (itemId == R.id.jump) { + FragmentManager fm = getSupportFragmentManager(); + JumpFragment jumpDialog = new JumpFragment(); + jumpDialog.show(fm, JumpFragment.TAG); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void refreshQuranPages() { + int pos = viewPager.getCurrentItem(); + int start = (pos == 0) ? pos : pos - 1; + int end = (pos == pagerAdapter.getCount() - 1) ? pos : pos + 1; + for (int i = start; i <= end; i++) { + Fragment f = pagerAdapter.getFragmentIfExists(i); + if (f instanceof QuranPage) { + ((QuranPage) f).updateView(); + } + } + } + + @Override + public boolean onSearchRequested() { + return super.onSearchRequested(); + } + + private void switchToQuran() { + pagerAdapter.setQuranMode(); + showingTranslation = false; + int page = getCurrentPage(); + supportInvalidateOptionsMenu(); + updateActionBarTitle(page); + + if (highlightedSura > 0 && highlightedAyah > 0) { + highlightAyah(highlightedSura, highlightedAyah, false, HighlightType.SELECTION); + } + } + + private void switchToTranslation() { + if (isInAyahMode) { + endAyahMode(); + } + String activeDatabase = TranslationUtils.getDefaultTranslation(this, translations); + if (activeDatabase == null) { + startTranslationManager(); + } else { + pagerAdapter.setTranslationMode(); + showingTranslation = true; + supportInvalidateOptionsMenu(); + updateActionBarSpinner(); + + if (highlightedSura > 0 && highlightedAyah > 0) { + highlightAyah(highlightedSura, highlightedAyah, false, HighlightType.SELECTION); + } + } + + if (!QuranFileUtils.hasArabicSearchDatabase(this) && !promptedForExtraDownload) { + promptedForExtraDownload = true; + showGetRequiredFilesDialog(); + } + } + + public void startTranslationManager() { + Intent i = new Intent(this, TranslationManagerActivity.class); + startActivity(i); + } + + private TranslationsSpinnerAdapter.OnSelectionChangedListener translationItemChangedListener = + selectedItems -> { + quranSettings.setActiveTranslations(selectedItems); + int pos = viewPager.getCurrentItem() - 1; + for (int count = 0; count < 3; count++) { + if (pos + count < 0) { + continue; + } + Fragment f = pagerAdapter.getFragmentIfExists(pos + count); + if (f instanceof TranslationFragment) { + ((TranslationFragment) f).refresh(); + } else if (f instanceof TabletFragment) { + ((TabletFragment) f).refresh(); + } + } + }; + + public List getTranslations() { + return translations; + } + + public String[] getTranslationNames() { + return translationItems; + } + + @Override + public void onAddTagSelected() { + FragmentManager fm = getSupportFragmentManager(); + AddTagDialog dialog = new AddTagDialog(); + dialog.show(fm, AddTagDialog.TAG); + } + + private void onBookmarksChanged() { + if (isInAyahMode) { + compositeDisposable.add( + bookmarkModel.getIsBookmarkedObservable(start.sura, start.ayah, start.getPage()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(Boolean isBookmarked) { + updateAyahBookmark(start, isBookmarked, true); + } + + @Override + public void onError(Throwable e) { + } + })); + } + } + + private void updateActionBarTitle(int page) { + String sura = QuranInfo.getSuraNameFromPage(this, page, true); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + translationsSpinner.setVisibility(View.GONE); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setTitle(sura); + String desc = QuranInfo.getPageSubtitle(this, page); + actionBar.setSubtitle(desc); + } + } + + private void refreshActionBarSpinner() { + if (translationsSpinnerAdapter != null) { + translationsSpinnerAdapter.notifyDataSetChanged(); + } else { + updateActionBarSpinner(); + } + } + + private int getCurrentPage() { + return QuranInfo.getPageFromPos(viewPager.getCurrentItem(), isDualPages); + } + + private void updateActionBarSpinner() { + if (translationItems == null || translationItems.length == 0) { + int page = getCurrentPage(); + updateActionBarTitle(page); + return; + } + + if (translationsSpinnerAdapter == null) { + translationsSpinnerAdapter = new TranslationsSpinnerAdapter(this, + R.layout.translation_ab_spinner_item, translationItems, translations, + quranSettings.getActiveTranslations(), + translationItemChangedListener) { + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + int type = super.getItemViewType(position); + if (type == 1) { + LayoutInflater inflater = getLayoutInflater(); + convertView = inflater.inflate(R.layout.translation_ab_spinner_selected, parent, false); + } else { + convertView = super.getView(position, convertView, parent); + SpinnerHolder holder = (SpinnerHolder) convertView.getTag(); + int page = getCurrentPage(); + + String sura = QuranInfo.getSuraNameFromPage(PagerActivity.this, page, true); + holder.title.setText(sura); + String desc = QuranInfo.getPageSubtitle(PagerActivity.this, page); + holder.subtitle.setText(desc); + holder.subtitle.setVisibility(View.VISIBLE); + } + return convertView; + } + }; + translationsSpinner.setAdapter(translationsSpinnerAdapter); + } + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + translationsSpinner.setVisibility(View.VISIBLE); + } + } + + private BroadcastReceiver audioReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null) { + int state = intent.getIntExtra( + AudioService.AudioUpdateIntent.STATUS, -1); + int sura = intent.getIntExtra( + AudioService.AudioUpdateIntent.SURA, -1); + int ayah = intent.getIntExtra( + AudioService.AudioUpdateIntent.AYAH, -1); + int repeatCount = intent.getIntExtra( + AudioService.AudioUpdateIntent.REPEAT_COUNT, -200); + AudioRequest request = intent.getParcelableExtra(AudioService.AudioUpdateIntent.REQUEST); + if (request != null) { + lastAudioRequest = request; + } + if (state == AudioService.AudioUpdateIntent.PLAYING) { + audioStatusBar.switchMode(AudioStatusBar.PLAYING_MODE); + highlightAyah(sura, ayah, HighlightType.AUDIO); + if (repeatCount >= -1) { + audioStatusBar.setRepeatCount(repeatCount); + } + } else if (state == AudioService.AudioUpdateIntent.PAUSED) { + audioStatusBar.switchMode(AudioStatusBar.PAUSED_MODE); + highlightAyah(sura, ayah, HighlightType.AUDIO); + } else if (state == AudioService.AudioUpdateIntent.STOPPED) { + audioStatusBar.switchMode(AudioStatusBar.STOPPED_MODE); + unHighlightAyahs(HighlightType.AUDIO); + lastAudioRequest = null; + + AudioRequest qi = intent.getParcelableExtra(AudioService.EXTRA_PLAY_INFO); + if (qi != null) { + // this means we stopped due to missing audio + } + } + } + } + }; + + @Override + public void updateDownloadProgress(int progress, + long downloadedSize, long totalSize) { + audioStatusBar.switchMode( + AudioStatusBar.DOWNLOADING_MODE); + audioStatusBar.setProgress(progress); + } + + @Override + public void updateProcessingProgress(int progress, + int processFiles, int totalFiles) { + audioStatusBar.setProgressText(getString(R.string.extracting_title), false); + audioStatusBar.setProgress(-1); + } + + @Override + public void handleDownloadTemporaryError(int errorId) { + audioStatusBar.setProgressText(getString(errorId), false); + } + + @Override + public void handleDownloadSuccess() { + refreshQuranPages(); + playAudioRequest(lastAudioDownloadRequest); + } + + @Override + public void handleDownloadFailure(int errId) { + String s = getString(errId); + audioStatusBar.setProgressText(s, true); + } + + public void toggleActionBarVisibility(boolean visible) { + if (visible == isActionBarHidden) { + toggleActionBar(); + } + } + + public void toggleActionBar() { + if (isActionBarHidden) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + setUiVisibility(true); + } else { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + toolBarArea.setVisibility(View.VISIBLE); + audioStatusBar.updateSelectedItem(); + audioStatusBar.setVisibility(View.VISIBLE); + } + + isActionBarHidden = false; + } else { + handler.removeMessages(MSG_HIDE_ACTIONBAR); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + setUiVisibility(false); + } else { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + toolBarArea.setVisibility(View.GONE); + audioStatusBar.setVisibility(View.GONE); + } + + isActionBarHidden = true; + } + } + + public QuranPageWorker getQuranPageWorker() { + return quranPageWorker; + } + + public void highlightAyah(int sura, int ayah, HighlightType type) { + if (type == HighlightType.AUDIO) { + lastPlayingSura = sura; + lastPlayingAyah = ayah; + } + highlightAyah(sura, ayah, true, type); + } + + private void highlightAyah(int sura, int ayah, + boolean force, HighlightType type) { + Timber.d("highlightAyah() - %s:%s", sura, ayah); + int page = QuranInfo.getPageFromSuraAyah(sura, ayah); + if (page < Constants.PAGES_FIRST || + PAGES_LAST < page) { + return; + } + + int position = QuranInfo.getPosFromPage(page, isDualPages); + if (position != viewPager.getCurrentItem() && force) { + unHighlightAyahs(type); + viewPager.setCurrentItem(position); + } + + Fragment f = pagerAdapter.getFragmentIfExists(position); + if (f instanceof QuranPage && f.isAdded()) { + ((QuranPage) f).getAyahTracker().highlightAyah(sura, ayah, type, true); + } + } + + private void unHighlightAyah(int sura, int ayah, HighlightType type) { + int position = viewPager.getCurrentItem(); + Fragment f = pagerAdapter.getFragmentIfExists(position); + if (f instanceof QuranPage && f.isVisible()) { + ((QuranPage) f).getAyahTracker().unHighlightAyah(sura, ayah, type); + } + } + + private void unHighlightAyahs(HighlightType type) { + if (type == HighlightType.AUDIO) { + lastPlayingSura = null; + lastPlayingAyah = null; + } + int position = viewPager.getCurrentItem(); + Fragment f = pagerAdapter.getFragmentIfExists(position); + if (f instanceof QuranPage && f.isVisible()) { + ((QuranPage) f).getAyahTracker().unHighlightAyahs(type); + } + } + + private void requestTranslationsList() { + compositeDisposable.add( + Single.fromCallable(() -> + translationsDBAdapter.getTranslations()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver>() { + @Override + public void onSuccess(List translationList) { + int items = translationList.size(); + String[] titles = new String[items]; + for (int i = 0; i < items; i++) { + LocalTranslation item = translationList.get(i); + if (!TextUtils.isEmpty(item.translatorForeign)) { + titles[i] = item.translatorForeign; + } else if (!TextUtils.isEmpty(item.translator)) { + titles[i] = item.translator; + } else { + titles[i] = item.name; + } + } + Set activeTranslations = quranSettings.getActiveTranslations(); + + if (translationsSpinnerAdapter != null) { + translationsSpinnerAdapter.updateItems(titles, translationList, activeTranslations); + } + translationItems = titles; + translations = translationList; + + if (showingTranslation) { + // Since translation items have changed, need to + updateActionBarSpinner(); + } + } + + @Override + public void onError(Throwable e) { + } + })); + } + + private void toggleBookmark(final Integer sura, final Integer ayah, final int page) { + compositeDisposable.add(bookmarkModel.toggleBookmarkObservable(sura, ayah, page) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(Boolean isBookmarked) { + if (sura == null || ayah == null) { + // page bookmark + bookmarksCache.put(page, isBookmarked); + supportInvalidateOptionsMenu(); + } else { + // ayah bookmark + SuraAyah suraAyah = new SuraAyah(sura, ayah); + updateAyahBookmark(suraAyah, isBookmarked, true); + } + } + + @Override + public void onError(Throwable e) { + } + })); + } + + private void checkIfPageIsBookmarked(Integer... pages) { + compositeDisposable.add(bookmarkModel.getIsBookmarkedObservable(pages) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableObserver>() { + + @Override + public void onNext(Pair result) { + bookmarksCache.put(result.first, result.second); + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + supportInvalidateOptionsMenu(); + } + })); + } + + // region Audio playback + + @Override + public void onPlayPressed() { + if (audioStatusBar.getCurrentMode() == AudioStatusBar.PAUSED_MODE) { + // if we are "paused," just un-pause. + play(null); + return; + } + + int position = viewPager.getCurrentItem(); + int page = PAGES_LAST - position; + if (isDualPages) { + page = ((PAGES_LAST_DUAL - position) * 2) - 1; + } + + int startSura = QuranInfo.safelyGetSuraOnPage(page); + int startAyah = QuranInfo.PAGE_AYAH_START[page - 1]; + playFromAyah(page, startSura, startAyah, false); + } + + private void playFromAyah(int page, int startSura, + int startAyah, boolean force) { + final SuraAyah start = new SuraAyah(startSura, startAyah); + playFromAyah(start, null, page, 0, 0, false, force); + } + + public void playFromAyah(SuraAyah start, SuraAyah end, + int page, int verseRepeat, int rangeRepeat, + boolean enforceRange, boolean force) { + if (force) { + shouldOverridePlaying = true; + } + + QariItem item = audioStatusBar.getAudioInfo(); + lastAudioDownloadRequest = getAudioDownloadRequest(start, end, page, item, + verseRepeat, rangeRepeat, enforceRange); + if (quranSettings.shouldStream() && lastAudioDownloadRequest != null && + !AudioUtils.haveAllFiles(lastAudioDownloadRequest)) { + playStreaming(start, end, page, item, verseRepeat, rangeRepeat, enforceRange); + } else { + playAudioRequest(lastAudioDownloadRequest); + } + } + + private void playStreaming(SuraAyah ayah, SuraAyah end, + int page, QariItem item, int verseRepeat, + int rangeRepeat, boolean enforceRange) { + String qariUrl = AudioUtils.getQariUrl(item); + String dbFile = AudioUtils.getQariDatabasePathIfGapless(this, item); + if (!TextUtils.isEmpty(dbFile)) { + // gapless audio is "download only" + lastAudioDownloadRequest = getAudioDownloadRequest(ayah, end, page, item, + verseRepeat, rangeRepeat, enforceRange); + playAudioRequest(lastAudioDownloadRequest); + return; + } + + final SuraAyah ending; + if (end != null) { + ending = end; + } else { + // this won't be enforced unless the user sets a range + // repeat, but we set it to a sane default anyway. + ending = AudioUtils.getLastAyahToPlay(ayah, page, + quranSettings.getPreferredDownloadAmount(), isDualPages); + } + AudioRequest request = new StreamingAudioRequest(qariUrl, ayah); + request.setPlayBounds(ayah, ending); + request.setEnforceBounds(enforceRange); + request.setRangeRepeatCount(rangeRepeat); + request.setVerseRepeatCount(verseRepeat); + play(request); + + audioStatusBar.switchMode(AudioStatusBar.PLAYING_MODE); + audioStatusBar.setRepeatCount(verseRepeat); + } + + @Nullable + private DownloadAudioRequest getAudioDownloadRequest(SuraAyah ayah, SuraAyah ending, + int page, @NonNull QariItem item, int verseRepeat, + int rangeRepeat, boolean enforceBounds) { + final SuraAyah endAyah; + if (ending != null) { + endAyah = ending; + } else { + endAyah = AudioUtils.getLastAyahToPlay(ayah, page, + quranSettings.getPreferredDownloadAmount(), isDualPages); + } + String baseUri = AudioUtils.getLocalQariUrl(this, item); + if (endAyah == null || baseUri == null) { + return null; + } + String dbFile = AudioUtils.getQariDatabasePathIfGapless(this, item); + + String fileUrl; + if (TextUtils.isEmpty(dbFile)) { + fileUrl = baseUri + File.separator + "%d" + File.separator + + "%d" + AudioUtils.AUDIO_EXTENSION; + } else { + fileUrl = baseUri + File.separator + "%03d" + + AudioUtils.AUDIO_EXTENSION; + } + + DownloadAudioRequest request = new DownloadAudioRequest(fileUrl, ayah, item, baseUri); + request.setGaplessDatabaseFilePath(dbFile); + request.setPlayBounds(ayah, endAyah); + request.setEnforceBounds(enforceBounds); + request.setRangeRepeatCount(rangeRepeat); + request.setVerseRepeatCount(verseRepeat); + + return request; + } + + private void playAudioRequest(@Nullable DownloadAudioRequest request) { + if (request == null) { + audioStatusBar.switchMode(AudioStatusBar.STOPPED_MODE); + return; + } + + boolean needsPermission = needsPermissionToDownloadOver3g; + if (needsPermission) { + if (QuranUtils.isOnWifiNetwork(this)) { + Timber.d("on wifi, don't need permission for download..."); + needsPermission = false; + } + } + + Timber.d("seeing if we can play audio request..."); + if (!QuranFileUtils.haveAyaPositionFile(this)) { + if (needsPermission) { + audioStatusBar.switchMode(AudioStatusBar.PROMPT_DOWNLOAD_MODE); + return; + } + + if (isActionBarHidden) { + toggleActionBar(); + } + audioStatusBar.switchMode(AudioStatusBar.DOWNLOADING_MODE); + String url = QuranFileUtils.getAyaPositionFileUrl(); + String destination = QuranFileUtils.getQuranDatabaseDirectory(this); + // start the download + String notificationTitle = getString(R.string.highlighting_database); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + destination, notificationTitle, AUDIO_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + startService(intent); + } else if (AudioUtils.shouldDownloadGaplessDatabase(request)) { + Timber.d("need to download gapless database..."); + if (needsPermission) { + audioStatusBar.switchMode(AudioStatusBar.PROMPT_DOWNLOAD_MODE); + return; + } + + if (isActionBarHidden) { + toggleActionBar(); + } + audioStatusBar.switchMode(AudioStatusBar.DOWNLOADING_MODE); + String url = AudioUtils.getGaplessDatabaseUrl(request); + String destination = request.getLocalPath(); + // start the download + String notificationTitle = getString(R.string.timing_database); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + destination, notificationTitle, AUDIO_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + startService(intent); + } else if (AudioUtils.haveAllFiles(request)) { + if (!AudioUtils.shouldDownloadBasmallah(request)) { + Timber.d("have all files, playing!"); + play(request); + lastAudioDownloadRequest = null; + } else { + Timber.d("should download basmalla..."); + if (needsPermission) { + audioStatusBar.switchMode(AudioStatusBar.PROMPT_DOWNLOAD_MODE); + return; + } + + SuraAyah firstAyah = new SuraAyah(1, 1); + String qariUrl = AudioUtils.getQariUrl(request.getQariItem()); + audioStatusBar.switchMode(AudioStatusBar.DOWNLOADING_MODE); + + if (isActionBarHidden) { + toggleActionBar(); + } + String notificationTitle = QuranInfo.getNotificationTitle( + this, firstAyah, firstAyah, request.isGapless()); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, qariUrl, + request.getLocalPath(), notificationTitle, + AUDIO_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + intent.putExtra(QuranDownloadService.EXTRA_START_VERSE, firstAyah); + intent.putExtra(QuranDownloadService.EXTRA_END_VERSE, firstAyah); + startService(intent); + } + } else { + if (needsPermission) { + audioStatusBar.switchMode(AudioStatusBar.PROMPT_DOWNLOAD_MODE); + return; + } + + if (isActionBarHidden) { + toggleActionBar(); + } + audioStatusBar.switchMode(AudioStatusBar.DOWNLOADING_MODE); + + String notificationTitle = QuranInfo.getNotificationTitle(this, + request.getMinAyah(), request.getMaxAyah(), request.isGapless()); + String qariUrl = AudioUtils.getQariUrl(request.getQariItem()); + Timber.d("need to start download: %s", qariUrl); + + // start service + Intent intent = ServiceIntentHelper.getDownloadIntent(this, qariUrl, + request.getLocalPath(), notificationTitle, AUDIO_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_AUDIO); + intent.putExtra(QuranDownloadService.EXTRA_START_VERSE, + request.getMinAyah()); + intent.putExtra(QuranDownloadService.EXTRA_END_VERSE, + request.getMaxAyah()); + intent.putExtra(QuranDownloadService.EXTRA_IS_GAPLESS, + request.isGapless()); + startService(intent); + } + } + + private void play(AudioRequest request) { + needsPermissionToDownloadOver3g = true; + Intent i = new Intent(this, AudioService.class); + i.setAction(AudioService.ACTION_PLAYBACK); + if (request != null) { + i.putExtra(AudioService.EXTRA_PLAY_INFO, request); + lastAudioRequest = request; + audioStatusBar.setRepeatCount(request.getVerseRepeatCount()); + } + + if (shouldOverridePlaying) { + // force the current audio to stop and start playing new request + i.putExtra(AudioService.EXTRA_STOP_IF_PLAYING, true); + shouldOverridePlaying = false; + } + // just a playback request, so tell audio service to just continue + // playing (and don't store new audio data) if it was already playing + else { + i.putExtra(AudioService.EXTRA_IGNORE_IF_PLAYING, true); + } + startService(i); + } + + @Override + public void onPausePressed() { + startService(AudioUtils.getAudioIntent( + this, AudioService.ACTION_PAUSE)); + audioStatusBar.switchMode(AudioStatusBar.PAUSED_MODE); + } + + @Override + public void onNextPressed() { + startService(AudioUtils.getAudioIntent(this, + AudioService.ACTION_SKIP)); + } + + @Override + public void onPreviousPressed() { + startService(AudioUtils.getAudioIntent(this, + AudioService.ACTION_REWIND)); + } + + @Override + public void onAudioSettingsPressed() { + if (lastPlayingSura != null) { + start = new SuraAyah(lastPlayingSura, lastPlayingAyah); + end = start; + } + + if (start == null) { + final int[] bounds = QuranInfo.getPageBounds(getCurrentPage()); + start = new SuraAyah(bounds[0], bounds[1]); + end = start; + } + showSlider(AUDIO_PAGE); + } + + public boolean updatePlayOptions(int rangeRepeat, + int verseRepeat, boolean enforceRange) { + if (lastAudioRequest != null) { + Intent i = new Intent(this, AudioService.class); + i.setAction(AudioService.ACTION_UPDATE_REPEAT); + i.putExtra(AudioService.EXTRA_VERSE_REPEAT_COUNT, verseRepeat); + i.putExtra(AudioService.EXTRA_RANGE_REPEAT_COUNT, rangeRepeat); + i.putExtra(AudioService.EXTRA_RANGE_RESTRICT, enforceRange); + startService(i); + + lastAudioRequest.setVerseRepeatCount(verseRepeat); + lastAudioRequest.setRangeRepeatCount(rangeRepeat); + lastAudioRequest.setEnforceBounds(enforceRange); + audioStatusBar.setRepeatCount(verseRepeat); + return true; + } else { + return false; + } + } + + @Override + public void setRepeatCount(int repeatCount) { + if (lastAudioRequest != null) { + Intent i = new Intent(this, AudioService.class); + i.setAction(AudioService.ACTION_UPDATE_REPEAT); + i.putExtra(AudioService.EXTRA_VERSE_REPEAT_COUNT, repeatCount); + startService(i); + lastAudioRequest.setVerseRepeatCount(repeatCount); + } + } + + @Override + public void onStopPressed() { + startService(AudioUtils.getAudioIntent(this, AudioService.ACTION_STOP)); + audioStatusBar.switchMode(AudioStatusBar.STOPPED_MODE); + unHighlightAyahs(HighlightType.AUDIO); + lastAudioRequest = null; + } + + @Override + public void onCancelPressed(boolean cancelDownload) { + if (cancelDownload) { + needsPermissionToDownloadOver3g = true; + + int resId = R.string.canceling; + audioStatusBar.setProgressText(getString(resId), true); + Intent i = new Intent(this, QuranDownloadService.class); + i.setAction(QuranDownloadService.ACTION_CANCEL_DOWNLOADS); + startService(i); + } else { + audioStatusBar.switchMode(AudioStatusBar.STOPPED_MODE); + } + } + + @Override + public void onAcceptPressed() { + if (lastAudioDownloadRequest != null) { + needsPermissionToDownloadOver3g = false; + playAudioRequest(lastAudioDownloadRequest); + } + } + + //endregion + + @Override + public void onBackPressed() { + if (isInAyahMode) { + endAyahMode(); + } else if (showingTranslation) { + switchToQuran(); + } else { + super.onBackPressed(); + } + } + + // region Ayah selection + + @Override + public boolean isListeningForAyahSelection(EventType eventType) { + return eventType == EventType.LONG_PRESS || + eventType == EventType.SINGLE_TAP && isInAyahMode; + } + + @Override + public boolean onAyahSelected(EventType eventType, SuraAyah suraAyah, AyahTracker tracker) { + switch (eventType) { + case SINGLE_TAP: + if (isInAyahMode) { + updateAyahStartSelection(suraAyah, tracker); + return true; + } + return false; + case LONG_PRESS: + if (isInAyahMode) { + updateAyahEndSelection(suraAyah); + } else { + startAyahMode(suraAyah, tracker); + } + viewPager.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS); + return true; + default: + return false; + } + } + + @Override + public boolean onClick(EventType eventType) { + switch (eventType) { + case SINGLE_TAP: + if (!isInAyahMode) { + toggleActionBar(); + return true; + } + return false; + case DOUBLE_TAP: + if (isInAyahMode) { + endAyahMode(); + return true; + } + return false; + default: + return false; + } + } + + public SuraAyah getSelectionStart() { + return start; + } + + public SuraAyah getSelectionEnd() { + return end; + } + + public AudioRequest getLastAudioRequest() { + return lastAudioRequest; + } + + private void startAyahMode(SuraAyah suraAyah, AyahTracker tracker) { + if (!isInAyahMode) { + start = end = suraAyah; + updateToolbarPosition(suraAyah, tracker); + ayahToolBar.showMenu(); + showAyahModeHighlights(suraAyah, tracker); + isInAyahMode = true; + } + } + + public void endAyahMode() { + ayahToolBar.hideMenu(); + slidingPanel.collapsePane(); + clearAyahModeHighlights(); + isInAyahMode = false; + } + + public void nextAyah() { + if (end != null) { + final int ayat = QuranInfo.getNumAyahs(end.sura); + + final SuraAyah s; + if (end.ayah + 1 <= ayat) { + s = new SuraAyah(end.sura, end.ayah + 1); + } else if (end.sura < 114) { + s = new SuraAyah(end.sura + 1, 1); + } else { + return; + } + selectAyah(s); + } + } + + public void previousAyah() { + if (end != null) { + final SuraAyah s; + if (end.ayah > 1) { + s = new SuraAyah(end.sura, end.ayah - 1); + } else if (end.sura > 1) { + s = new SuraAyah(end.sura - 1, QuranInfo.getNumAyahs(end.sura - 1)); + } else { + return; + } + selectAyah(s); + } + } + + private void selectAyah(SuraAyah s) { + final int page = s.getPage(); + final int position = QuranInfo.getPosFromPage(page, isDualPages); + Fragment f = pagerAdapter.getFragmentIfExists(position); + if (f instanceof QuranPage && f.isVisible()) { + if (position != viewPager.getCurrentItem()) { + viewPager.setCurrentItem(position); + } + updateAyahStartSelection(s, ((QuranPage) f).getAyahTracker()); + } + } + + private void updateAyahStartSelection(SuraAyah suraAyah, AyahTracker tracker) { + if (isInAyahMode) { + clearAyahModeHighlights(); + start = end = suraAyah; + if (ayahToolBar.isShowing()) { + ayahToolBar.resetMenu(); + updateToolbarPosition(suraAyah, tracker); + } + if (slidingPanel.isPaneVisible()) { + refreshPages(); + } + showAyahModeHighlights(suraAyah, tracker); + } + } + + private void updateAyahEndSelection(SuraAyah suraAyah) { + if (isInAyahMode) { + clearAyahModeHighlights(); + if (suraAyah.after(start)) { + end = suraAyah; + } else { + end = start; + start = suraAyah; + } + if (slidingPanel.isPaneVisible()) { + refreshPages(); + } + showAyahModeRangeHighlights(); + } + } + + //endregion + + private void updateToolbarPosition(final SuraAyah start, AyahTracker tracker) { + compositeDisposable.add(bookmarkModel + .getIsBookmarkedObservable(start.sura, start.ayah, start.getPage()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(Boolean isBookmarked) { + updateAyahBookmark(start, isBookmarked, false); + } + + @Override + public void onError(Throwable e) { + } + })); + + ayahToolBarPos = tracker.getToolBarPosition(start.sura, start.ayah, + ayahToolBar.getToolBarWidth(), ayahToolBarTotalHeight); + if (ayahToolBarPos != null) { + ayahToolBar.updatePosition(ayahToolBarPos); + if (ayahToolBar.getVisibility() != View.VISIBLE) { + ayahToolBar.setVisibility(View.VISIBLE); + } + } + } + + // Used to sync toolbar with page's SV (landscape non-tablet mode) + public void onQuranPageScroll(int scrollY) { + if (ayahToolBarPos != null) { + ayahToolBarPos.yScroll = 0 - scrollY; + if (isInAyahMode) { + ayahToolBar.updatePosition(ayahToolBarPos); + } + } + } + + private void refreshPages() { + for (int page : PAGES) { + final int mappedTagPage = slidingPagerAdapter.getPagePosition(TAG_PAGE); + if (page == mappedTagPage) { + Fragment fragment = slidingPagerAdapter.getFragmentIfExists(mappedTagPage); + if (fragment instanceof TagBookmarkDialog && start != null) { + ((TagBookmarkDialog) fragment).updateAyah(start); + } + } else { + AyahActionFragment f = (AyahActionFragment) slidingPagerAdapter + .getFragmentIfExists(page); + if (f != null) { + f.updateAyahSelection(start, end); + } + } + } + } + + private void showAyahModeRangeHighlights() { + // Determine the start and end of the selection + int minPage = Math.min(start.getPage(), end.getPage()); + int maxPage = Math.max(start.getPage(), end.getPage()); + SuraAyah start = SuraAyah.min(this.start, end); + SuraAyah end = SuraAyah.max(this.start, this.end); + // Iterate from beginning to end + for (int i = minPage; i <= maxPage; i++) { + QuranPage fragment = pagerAdapter.getFragmentIfExistsForPage(i); + if (fragment != null) { + Set ayahKeys = QuranInfo.getAyahKeysOnPage(i, start, end); + fragment.getAyahTracker().highlightAyat(i, ayahKeys, HighlightType.SELECTION); + } + } + } + + private void showAyahModeHighlights(SuraAyah suraAyah, AyahTracker tracker) { + tracker.highlightAyah( + suraAyah.sura, suraAyah.ayah, HighlightType.SELECTION, false); + } + + private void clearAyahModeHighlights() { + if (isInAyahMode) { + for (int i = start.getPage(); i <= end.getPage(); i++) { + QuranPage fragment = pagerAdapter.getFragmentIfExistsForPage(i); + if (fragment != null) { + fragment.getAyahTracker().unHighlightAyahs(HighlightType.SELECTION); + } + } + } + } + + private class AyahMenuItemSelectionHandler implements MenuItem.OnMenuItemClickListener { + @Override + public boolean onMenuItemClick(MenuItem item) { + int sliderPage = -1; + if (start == null || end == null) { + return false; + } + + switch (item.getItemId()) { + case R.id.cab_bookmark_ayah: + toggleBookmark(start.sura, start.ayah, start.getPage()); + break; + case R.id.cab_tag_ayah: + sliderPage = slidingPagerAdapter.getPagePosition(TAG_PAGE); + break; + case R.id.cab_translate_ayah: + sliderPage = slidingPagerAdapter.getPagePosition(TRANSLATION_PAGE); + break; + case R.id.cab_play_from_here: + sliderPage = slidingPagerAdapter.getPagePosition(AUDIO_PAGE); + break; + case R.id.cab_share_ayah_link: + shareAyahLink(start, end); + break; + case R.id.cab_share_ayah_text: + shareAyah(start, end, false); + break; + case R.id.cab_copy_ayah: + shareAyah(start, end, true); + break; + default: + return false; + } + if (sliderPage < 0) { + endAyahMode(); + } else { + showSlider(sliderPage); + } + return true; + } + } + + private void shareAyah(SuraAyah start, SuraAyah end, final boolean isCopy) { + if (start == null || end == null) { + return; + } + + compositeDisposable.add( + arabicDatabaseUtils + .getVerses(start, end) + .filter(quranAyahs -> quranAyahs.size() > 0) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(quranAyahs -> { + if (isCopy) { + ShareUtil.copyVerses(PagerActivity.this, quranAyahs); + } else { + ShareUtil.shareVerses(PagerActivity.this, quranAyahs); + } + })); + } + + private void shareAyahLink(SuraAyah start, SuraAyah end) { + showProgressDialog(); + compositeDisposable.add( + QuranAppUtils.getQuranAppUrlObservable(getString(R.string.quranapp_key), start, end) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(String url) { + ShareUtil.shareViaIntent(PagerActivity.this, url, R.string.share_ayah); + dismissProgressDialog(); + } + + @Override + public void onError(Throwable e) { + dismissProgressDialog(); + } + }) + ); + } + + private void showProgressDialog() { + if (progressDialog == null) { + progressDialog = new ProgressDialog(this); + progressDialog.setIndeterminate(true); + progressDialog.setMessage(getString(R.string.index_loading)); + progressDialog.show(); + } + } + + private void dismissProgressDialog() { + if (progressDialog != null && progressDialog.isShowing()) { + progressDialog.dismiss(); + } + progressDialog = null; + } + + private void showSlider(int sliderPage) { + ayahToolBar.hideMenu(); + slidingPager.setCurrentItem(sliderPage); + slidingPanel.showPane(); + // TODO there's got to be a better way than this hack + // The issue is that smoothScrollTo returns if mCanSlide is false + // and it's false when the panel is GONE and showPane only calls + // requestLayout, and only in onLayout does mCanSlide become true. + // So by posting this later it gives time for onLayout to run. + // Another issue is that the fragments haven't been created yet + // (on first run), so calling refreshPages() before then won't work. + handler.post(() -> { + slidingPanel.expandPane(); + refreshPages(); + }); + } + + private void updateAyahBookmark( + SuraAyah suraAyah, boolean bookmarked, boolean refreshHighlight) { + // Refresh toolbar icon + if (isInAyahMode && start.equals(suraAyah)) { + ayahToolBar.setBookmarked(bookmarked); + } + // Refresh highlight + if (refreshHighlight && quranSettings.shouldHighlightBookmarks()) { + if (bookmarked) { + highlightAyah(suraAyah.sura, suraAyah.ayah, HighlightType.BOOKMARK); + } else { + unHighlightAyah(suraAyah.sura, suraAyah.ayah, HighlightType.BOOKMARK); + } + } + } + + private class SlidingPanelListener implements SlidingUpPanelLayout.PanelSlideListener { + + @Override + public void onPanelSlide(View panel, float slideOffset) { + } + + @Override + public void onPanelCollapsed(View panel) { + if (isInAyahMode) { + endAyahMode(); + } + slidingPanel.hidePane(); + } + + @Override + public void onPanelExpanded(View panel) { + } + + @Override + public void onPanelAnchored(View panel) { + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/QuranActionBarActivity.java b/app/src/main/java/com/quran/labs/androidquran/ui/QuranActionBarActivity.java new file mode 100644 index 0000000000..d54ca01da6 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/QuranActionBarActivity.java @@ -0,0 +1,31 @@ +package com.quran.labs.androidquran.ui; + +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.v7.app.AppCompatActivity; +import android.view.KeyEvent; + +public abstract class QuranActionBarActivity extends AppCompatActivity { + + /** + * work around an LG bug on 4.1.2. + * see http://stackoverflow.com/questions/26833242 + * and also https://code.google.com/p/android/issues/detail?id=78154 + */ + private static final boolean sBuggyMenuVersion = + Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN && + "LGE".equalsIgnoreCase(Build.BRAND); + + public boolean onKeyDown(int keyCode, KeyEvent event) { + return keyCode == KeyEvent.KEYCODE_MENU && sBuggyMenuVersion || super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_MENU && sBuggyMenuVersion) { + openOptionsMenu(); + return true; + } + return super.onKeyUp(keyCode, event); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/QuranActivity.java b/app/src/main/java/com/quran/labs/androidquran/ui/QuranActivity.java new file mode 100644 index 0000000000..82884725e3 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/QuranActivity.java @@ -0,0 +1,434 @@ +package com.quran.labs.androidquran.ui; + +import android.app.SearchManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.MenuItemCompat; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AlertDialog; +import android.support.v7.view.ActionMode; +import android.support.v7.widget.SearchView; +import android.support.v7.widget.Toolbar; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.CustomEvent; +import com.quran.labs.androidquran.AboutUsActivity; +import com.quran.labs.androidquran.HelpActivity; +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.QuranPreferenceActivity; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.SearchActivity; +import com.quran.labs.androidquran.ShortcutsActivity; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.model.bookmark.RecentPageModel; +import com.quran.labs.androidquran.presenter.translation.TranslationManagerPresenter; +import com.quran.labs.androidquran.service.AudioService; +import com.quran.labs.androidquran.ui.fragment.AddTagDialog; +import com.quran.labs.androidquran.ui.fragment.BookmarksFragment; +import com.quran.labs.androidquran.ui.fragment.JumpFragment; +import com.quran.labs.androidquran.ui.fragment.JuzListFragment; +import com.quran.labs.androidquran.ui.fragment.SuraListFragment; +import com.quran.labs.androidquran.ui.fragment.TagBookmarkDialog; +import com.quran.labs.androidquran.util.AudioUtils; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.widgets.SlidingTabLayout; + +import javax.inject.Inject; + +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import timber.log.Timber; + +public class QuranActivity extends QuranActionBarActivity + implements TagBookmarkDialog.OnBookmarkTagsUpdateListener { + + private static int[] TITLES = new int[]{ + R.string.quran_sura, + R.string.quran_juz2, + R.string.menu_bookmarks }; + private static int[] ARABIC_TITLES = new int[]{ + R.string.menu_bookmarks, + R.string.quran_juz2, + R.string.quran_sura }; + + public static final String EXTRA_SHOW_TRANSLATION_UPGRADE = "transUp"; + private static final String SI_SHOWED_UPGRADE_DIALOG = "si_showed_dialog"; + + private static final int SURA_LIST = 0; + private static final int JUZ2_LIST = 1; + private static final int BOOKMARKS_LIST = 2; + + private static boolean updatedTranslations; + + private AlertDialog upgradeDialog = null; + private boolean showedTranslationUpgradeDialog = false; + private boolean isRtl; + private boolean isPaused; + private MenuItem searchItem; + private ActionMode supportActionMode; + private CompositeDisposable compositeDisposable; + private QuranSettings settings; + private Observable recentPages; + + @Inject RecentPageModel recentPageModel; + @Inject TranslationManagerPresenter translationManagerPresenter; + + @Override + public void onCreate(Bundle savedInstanceState) { + QuranApplication quranApp = (QuranApplication) getApplication(); + quranApp.refreshLocale(this, false); + + super.onCreate(savedInstanceState); + quranApp.getApplicationComponent().inject(this); + + setContentView(R.layout.quran_index); + compositeDisposable = new CompositeDisposable(); + + settings = QuranSettings.getInstance(this); + isRtl = isRtl(); + + final Toolbar tb = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(tb); + + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setTitle(R.string.app_name); + } + + final ViewPager pager = (ViewPager) findViewById(R.id.index_pager); + pager.setOffscreenPageLimit(3); + PagerAdapter pagerAdapter = new PagerAdapter(getSupportFragmentManager()); + pager.setAdapter(pagerAdapter); + + final SlidingTabLayout indicator = + (SlidingTabLayout) findViewById(R.id.indicator); + indicator.setViewPager(pager); + + if (isRtl) { + pager.setCurrentItem(TITLES.length - 1); + } + + if (savedInstanceState != null) { + showedTranslationUpgradeDialog = savedInstanceState.getBoolean( + SI_SHOWED_UPGRADE_DIALOG, false); + } + + recentPages = recentPageModel.getLatestPageObservable(); + Intent intent = getIntent(); + if (intent != null) { + Bundle extras = intent.getExtras(); + if (extras != null) { + if (extras.getBoolean(EXTRA_SHOW_TRANSLATION_UPGRADE, false)) { + if (!showedTranslationUpgradeDialog) { + showTranslationsUpgradeDialog(); + } + } + } + + if (ShortcutsActivity.ACTION_JUMP_TO_LATEST.equals(intent.getAction())) { + jumpToLastPage(); + } + } + + updateTranslationsListAsNeeded(); + } + + @Override + public void onResume() { + compositeDisposable.add(recentPages.subscribe()); + + super.onResume(); + final boolean isRtl = isRtl(); + if (isRtl != this.isRtl) { + final Intent i = getIntent(); + finish(); + startActivity(i); + } else { + startService(AudioUtils.getAudioIntent(this, AudioService.ACTION_STOP)); + } + isPaused = false; + } + + @Override + protected void onPause() { + compositeDisposable.clear(); + isPaused = true; + super.onPause(); + } + + private boolean isRtl() { + return settings.isArabicNames() || QuranUtils.isRtl(); + } + + public Observable getLatestPageObservable() { + return recentPages; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.home_menu, menu); + searchItem = menu.findItem(R.id.search); + final SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchItem); + final SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); + searchView.setQueryHint(getString(R.string.search_hint)); + searchView.setSearchableInfo(searchManager.getSearchableInfo( + new ComponentName(this, SearchActivity.class))); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.settings: { + Intent i = new Intent(this, QuranPreferenceActivity.class); + startActivity(i); + return true; + } + case R.id.last_page: { + jumpToLastPage(); + return true; + } + case R.id.help: { + Intent i = new Intent(this, HelpActivity.class); + startActivity(i); + return true; + } + case R.id.about: { + Intent i = new Intent(this, AboutUsActivity.class); + startActivity(i); + return true; + } + case R.id.jump: { + gotoPageDialog(); + return true; + } + case R.id.other_apps: { + Answers.getInstance().logCustom(new CustomEvent("menuOtherApps")); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("market://search?q=pub:quran.com")); + if (getPackageManager().resolveActivity(intent, + PackageManager.MATCH_DEFAULT_ONLY) == null) { + intent.setData(Uri.parse("http://play.google.com/store/search?q=pub:quran.com")); + } + startActivity(intent); + return true; + } + default: { + return super.onOptionsItemSelected(item); + } + } + } + + @Override + public void onSupportActionModeFinished(@NonNull ActionMode mode) { + supportActionMode = null; + super.onSupportActionModeFinished(mode); + } + + @Override + public void onSupportActionModeStarted(@NonNull ActionMode mode) { + supportActionMode = mode; + super.onSupportActionModeStarted(mode); + } + + @Override + public void onBackPressed() { + if (supportActionMode != null) { + supportActionMode.finish(); + } else if (searchItem != null && searchItem.isActionViewExpanded()) { + searchItem.collapseActionView(); + } else { + super.onBackPressed(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putBoolean(SI_SHOWED_UPGRADE_DIALOG, + showedTranslationUpgradeDialog); + super.onSaveInstanceState(outState); + } + + private void jumpToLastPage() { + compositeDisposable.add(recentPages + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(recentPage -> jumpTo(recentPage == Constants.NO_PAGE ? 1 : recentPage))); + } + + private void updateTranslationsListAsNeeded() { + if (!updatedTranslations) { + long time = settings.getLastUpdatedTranslationDate(); + Timber.d("checking whether we should update translations.."); + if (System.currentTimeMillis() - time > Constants.TRANSLATION_REFRESH_TIME) { + Timber.d("updating translations list..."); + updatedTranslations = true; + translationManagerPresenter.checkForUpdates(); + } + } + } + + private void showTranslationsUpgradeDialog() { + showedTranslationUpgradeDialog = true; + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.translation_updates_available); + builder.setCancelable(false); + builder.setPositiveButton(R.string.translation_dialog_yes, + (dialog, id) -> { + dialog.dismiss(); + upgradeDialog = null; + launchTranslationActivity(); + }); + + builder.setNegativeButton(R.string.translation_dialog_later, + (dialog, which) -> { + dialog.dismiss(); + upgradeDialog = null; + + // pretend we don't have updated translations. we'll + // check again after 10 days. + settings.setHaveUpdatedTranslations(false); + }); + + upgradeDialog = builder.create(); + upgradeDialog.show(); + } + + private void launchTranslationActivity() { + Intent i = new Intent(this, TranslationManagerActivity.class); + startActivity(i); + } + + public void jumpTo(int page) { + Intent i = new Intent(this, PagerActivity.class); + i.putExtra("page", page); + i.putExtra(PagerActivity.EXTRA_JUMP_TO_TRANSLATION, settings.getWasShowingTranslation()); + startActivity(i); + } + + public void jumpToAndHighlight(int page, int sura, int ayah) { + Intent i = new Intent(this, PagerActivity.class); + i.putExtra("page", page); + i.putExtra(PagerActivity.EXTRA_HIGHLIGHT_SURA, sura); + i.putExtra(PagerActivity.EXTRA_HIGHLIGHT_AYAH, ayah); + startActivity(i); + } + + private void gotoPageDialog() { + if (!isPaused) { + FragmentManager fm = getSupportFragmentManager(); + JumpFragment jumpDialog = new JumpFragment(); + jumpDialog.show(fm, JumpFragment.TAG); + } + } + + public void addTag() { + if (!isPaused) { + FragmentManager fm = getSupportFragmentManager(); + AddTagDialog addTagDialog = new AddTagDialog(); + addTagDialog.show(fm, AddTagDialog.TAG); + } + } + + public void editTag(long id, String name) { + if (!isPaused) { + FragmentManager fm = getSupportFragmentManager(); + AddTagDialog addTagDialog = AddTagDialog.newInstance(id, name); + addTagDialog.show(fm, AddTagDialog.TAG); + } + } + + public void tagBookmarks(long[] ids) { + if (ids != null && ids.length == 1) { + tagBookmark(ids[0]); + return; + } + + if (!isPaused) { + FragmentManager fm = getSupportFragmentManager(); + TagBookmarkDialog tagBookmarkDialog = TagBookmarkDialog.newInstance(ids); + tagBookmarkDialog.show(fm, TagBookmarkDialog.TAG); + } + } + + private void tagBookmark(long id) { + if (!isPaused) { + FragmentManager fm = getSupportFragmentManager(); + TagBookmarkDialog tagBookmarkDialog = TagBookmarkDialog.newInstance(id); + tagBookmarkDialog.show(fm, TagBookmarkDialog.TAG); + } + } + + @Override + public void onAddTagSelected() { + FragmentManager fm = getSupportFragmentManager(); + AddTagDialog dialog = new AddTagDialog(); + dialog.show(fm, AddTagDialog.TAG); + } + + private class PagerAdapter extends FragmentPagerAdapter { + + PagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public int getCount() { + return 3; + } + + @Override + public Fragment getItem(int position) { + int pos = position; + if (isRtl) { + pos = Math.abs(position - 2); + } + + switch (pos) { + case QuranActivity.SURA_LIST: + return SuraListFragment.newInstance(); + case QuranActivity.JUZ2_LIST: + return JuzListFragment.newInstance(); + case QuranActivity.BOOKMARKS_LIST: + default: + return BookmarksFragment.newInstance(); + } + } + + @Override + public long getItemId(int position) { + int pos = isRtl ? Math.abs(position - 2) : position; + switch (pos) { + case QuranActivity.SURA_LIST: + return SURA_LIST; + case QuranActivity.JUZ2_LIST: + return JUZ2_LIST; + case QuranActivity.BOOKMARKS_LIST: + default: + return BOOKMARKS_LIST; + } + } + + @Override + public CharSequence getPageTitle(int position) { + final int resId = isRtl ? + ARABIC_TITLES[position] : TITLES[position]; + return getString(resId); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/TranslationManagerActivity.java b/app/src/main/java/com/quran/labs/androidquran/ui/TranslationManagerActivity.java new file mode 100644 index 0000000000..f1e2857bfe --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/TranslationManagerActivity.java @@ -0,0 +1,323 @@ +package com.quran.labs.androidquran.ui; + +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.support.design.widget.Snackbar; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.widget.SwipeRefreshLayout; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.SparseIntArray; +import android.view.MenuItem; + +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.translation.TranslationHeader; +import com.quran.labs.androidquran.dao.translation.TranslationItem; +import com.quran.labs.androidquran.dao.translation.TranslationRowData; +import com.quran.labs.androidquran.presenter.translation.TranslationManagerPresenter; +import com.quran.labs.androidquran.service.QuranDownloadService; +import com.quran.labs.androidquran.service.util.DefaultDownloadReceiver; +import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; +import com.quran.labs.androidquran.service.util.ServiceIntentHelper; +import com.quran.labs.androidquran.ui.adapter.TranslationsAdapter; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + +import butterknife.BindView; +import butterknife.ButterKnife; +import io.reactivex.disposables.Disposable; +import timber.log.Timber; + +public class TranslationManagerActivity extends QuranActionBarActivity + implements DefaultDownloadReceiver.SimpleDownloadListener { + + public static final String TRANSLATION_DOWNLOAD_KEY = "TRANSLATION_DOWNLOAD_KEY"; + private static final String UPGRADING_EXTENSION = ".old"; + + private List allItems; + private SparseIntArray translationPositions; + + private TranslationsAdapter adapter; + private TranslationItem downloadingItem; + private String databaseDirectory; + private QuranSettings quranSettings; + private DefaultDownloadReceiver mDownloadReceiver = null; + + private Disposable onClickDownloadDisposable; + private Disposable onClickRemoveDisposable; + + @Inject + TranslationManagerPresenter presenter; + + @BindView(R.id.translation_swipe_refresh) + SwipeRefreshLayout translationSwipeRefresh; + + @BindView(R.id.translation_recycler) + RecyclerView translationRecycler; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ((QuranApplication) getApplication()).getApplicationComponent().inject(this); + setContentView(R.layout.translation_manager); + ButterKnife.bind(this); + + RecyclerView.LayoutManager mLayoutManager = new LinearLayoutManager(this); + translationRecycler.setLayoutManager(mLayoutManager); + + adapter = new TranslationsAdapter(this); + translationRecycler.setAdapter(adapter); + + databaseDirectory = QuranFileUtils.getQuranDatabaseDirectory(this); + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.prefs_translations); + } + + quranSettings = QuranSettings.getInstance(this); + presenter.bind(this); + presenter.getTranslationsList(false); + onClickDownloadDisposable = adapter.getOnClickDownloadSubject().subscribe(this::downloadItem); + onClickRemoveDisposable = adapter.getOnClickRemoveSubject().subscribe(this::removeItem); + + translationSwipeRefresh.setOnRefreshListener(this::onRefresh); + } + + @Override + public void onStop() { + if (mDownloadReceiver != null) { + mDownloadReceiver.setListener(null); + LocalBroadcastManager.getInstance(this) + .unregisterReceiver(mDownloadReceiver); + mDownloadReceiver = null; + } + super.onStop(); + } + + @Override + protected void onDestroy() { + presenter.unbind(this); + onClickDownloadDisposable.dispose(); + onClickRemoveDisposable.dispose(); + super.onDestroy(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + @Override + public void handleDownloadSuccess() { + if (downloadingItem != null) { + if (downloadingItem.exists()) { + try { + File f = new File(databaseDirectory, + downloadingItem.translation.filename + UPGRADING_EXTENSION); + if (f.exists()) { + f.delete(); + } + } catch (Exception e) { + Timber.d(e, "error removing old database file"); + } + } + TranslationItem updated = downloadingItem.withTranslationVersion( + downloadingItem.translation.currentVersion); + updateTranslationItem(updated); + } + downloadingItem = null; + generateListItems(); + } + + @Override + public void handleDownloadFailure(int errId) { + if (downloadingItem != null && downloadingItem.exists()) { + try { + File f = new File(databaseDirectory, + downloadingItem.translation.filename + UPGRADING_EXTENSION); + File destFile = new File(databaseDirectory, downloadingItem.translation.filename); + if (f.exists() && !destFile.exists()) { + f.renameTo(destFile); + } else { + f.delete(); + } + } catch (Exception e) { + Timber.d(e, "error restoring translation after failed download"); + } + } + downloadingItem = null; + } + + private void onRefresh() { + presenter.getTranslationsList(true); + } + + private void updateTranslationItem(TranslationItem updated) { + int id = updated.translation.id; + int allItemsIndex = translationPositions.get(id); + if (allItems != null && allItems.size() > allItemsIndex) { + allItems.remove(allItemsIndex); + allItems.add(allItemsIndex, updated); + } + presenter.updateItem(updated); + } + + public void onErrorDownloadTranslations() { + translationSwipeRefresh.setRefreshing(false); + Snackbar + .make(translationRecycler, R.string.error_getting_translation_list, Snackbar.LENGTH_SHORT) + .show(); + } + + public void onTranslationsUpdated(List items) { + translationSwipeRefresh.setRefreshing(false); + SparseIntArray itemsSparseArray = new SparseIntArray(items.size()); + for (int i = 0, itemsSize = items.size(); i < itemsSize; i++) { + TranslationItem item = items.get(i); + itemsSparseArray.put(item.translation.id, i); + } + allItems = items; + translationPositions = itemsSparseArray; + + generateListItems(); + } + + private void generateListItems() { + if (allItems == null) { + return; + } + + List downloaded = new ArrayList<>(); + List notDownloaded = new ArrayList<>(); + for (int i = 0, mAllItemsSize = allItems.size(); i < mAllItemsSize; i++) { + TranslationItem item = allItems.get(i); + if (item.exists()) { + downloaded.add(item); + } else { + notDownloaded.add(item); + } + } + + List result = new ArrayList<>(); + if (downloaded.size() > 0) { + TranslationHeader hdr = new TranslationHeader(getString(R.string.downloaded_translations)); + result.add(hdr); + + boolean needsUpgrade = false; + for (TranslationItem item : downloaded) { + result.add(item); + needsUpgrade = needsUpgrade || item.needsUpgrade(); + } + + if (!needsUpgrade) { + quranSettings.setHaveUpdatedTranslations(false); + } + } + + result.add(new TranslationHeader(getString(R.string.available_translations))); + + result.addAll(notDownloaded); + + adapter.setTranslations(result); + adapter.notifyDataSetChanged(); + } + + private void downloadItem(TranslationRowData translationRowData) { + TranslationItem selectedItem = (TranslationItem) translationRowData; + if (selectedItem.exists() && !selectedItem.needsUpgrade()) { + return; + } + + downloadingItem = selectedItem; + if (mDownloadReceiver == null) { + mDownloadReceiver = new DefaultDownloadReceiver(this, + QuranDownloadService.DOWNLOAD_TYPE_TRANSLATION); + LocalBroadcastManager.getInstance(this).registerReceiver( + mDownloadReceiver, new IntentFilter( + QuranDownloadNotifier.ProgressIntent.INTENT_NAME)); + } + mDownloadReceiver.setListener(this); + + // actually start the download + String url = selectedItem.translation.fileUrl; + if (selectedItem.translation.fileUrl == null) { + return; + } + String destination = databaseDirectory; + Timber.d("downloading %s to %s", url, destination); + + if (selectedItem.exists()) { + try { + File f = new File(destination, selectedItem.translation.filename); + if (f.exists()) { + File newPath = new File(destination, + selectedItem.translation.filename + UPGRADING_EXTENSION); + if (newPath.exists()) { + newPath.delete(); + } + f.renameTo(newPath); + } + } catch (Exception e) { + Timber.d(e, "error backing database file up"); + } + } + + // start the download + String notificationTitle = selectedItem.name(); + Intent intent = ServiceIntentHelper.getDownloadIntent(this, url, + destination, notificationTitle, TRANSLATION_DOWNLOAD_KEY, + QuranDownloadService.DOWNLOAD_TYPE_TRANSLATION); + String filename = selectedItem.translation.filename; + if (url.endsWith("zip")) { + filename += ".zip"; + } + intent.putExtra(QuranDownloadService.EXTRA_OUTPUT_FILE_NAME, filename); + startService(intent); + } + + private void removeItem(final TranslationRowData translationRowData) { + if (adapter == null) { + return; + } + + final TranslationItem selectedItem = + (TranslationItem) translationRowData; + String msg = String.format(getString(R.string.remove_dlg_msg), selectedItem.name()); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.remove_dlg_title) + .setMessage(msg) + .setPositiveButton(R.string.remove_button, + (dialog, id) -> { + QuranFileUtils.removeTranslation(TranslationManagerActivity.this, + selectedItem.translation.filename); + TranslationItem updatedItem = selectedItem.withTranslationRemoved(); + updateTranslationItem(updatedItem); + String current = quranSettings.getActiveTranslation(); + if (current.equals(selectedItem.translation.filename)) { + quranSettings.removeActiveTranslation(); + } + generateListItems(); + }) + .setNegativeButton(R.string.cancel, + (dialog, i) -> dialog.dismiss()); + builder.show(); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/adapter/TranslationsAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/adapter/TranslationsAdapter.java new file mode 100644 index 0000000000..751604aeee --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/adapter/TranslationsAdapter.java @@ -0,0 +1,172 @@ +package com.quran.labs.androidquran.ui.adapter; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.translation.TranslationItem; +import com.quran.labs.androidquran.dao.translation.TranslationRowData; + +import java.util.ArrayList; +import java.util.List; + +import butterknife.BindView; +import butterknife.ButterKnife; +import io.reactivex.Observable; +import io.reactivex.subjects.UnicastSubject; + +public class TranslationsAdapter extends RecyclerView.Adapter { + + private final UnicastSubject onClickDownloadSubject = UnicastSubject.create(); + private final UnicastSubject onClickRemoveSubject = UnicastSubject.create(); + + private List translations = new ArrayList<>(); + private Context context; + + public TranslationsAdapter(Context context) { + this.context = context; + } + + @Override + public TranslationViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); + return new TranslationViewHolder(view, viewType); + } + + @Override + public void onBindViewHolder(TranslationViewHolder holder, int position) { + TranslationRowData rowItem = translations.get(position); + switch (holder.getItemViewType()) { + case R.layout.translation_row: + TranslationItem item = (TranslationItem) rowItem; + holder.getTranslationTitle().setText(item.name()); + if (TextUtils.isEmpty(item.translation.translatorNameLocalized)) { + holder.getTranslationInfo().setText(item.translation.translator); + } else { + holder.getTranslationInfo().setText(item.translation.translatorNameLocalized); + } + + ImageView leftImage = holder.getLeftImage(); + ImageView rightImage = holder.getRightImage(); + + if (item.exists()) { + if (item.needsUpgrade()) { + leftImage.setImageResource(R.drawable.ic_download); + leftImage.setVisibility(View.VISIBLE); + holder.getTranslationInfo().setText(R.string.update_available); + } else { + leftImage.setVisibility(View.GONE); + } + rightImage.setImageResource(R.drawable.ic_cancel); + rightImage.setVisibility(View.VISIBLE); + rightImage.setContentDescription(context.getString(R.string.remove_button)); + } else { + leftImage.setVisibility(View.GONE); + rightImage.setImageResource(R.drawable.ic_download); + rightImage.setVisibility(View.VISIBLE); + rightImage.setOnClickListener(null); + rightImage.setClickable(false); + rightImage.setContentDescription(null); + } + break; + case R.layout.translation_sep: + holder.getSeparatorText().setText(rowItem.name()); + break; + } + } + + @Override + public int getItemCount() { + return translations.size(); + } + + @Override + public int getItemViewType(int position) { + return translations.get(position).isSeparator() ? + R.layout.translation_sep : R.layout.translation_row; + } + + public Observable getOnClickDownloadSubject() { + return onClickDownloadSubject.hide(); + } + + public Observable getOnClickRemoveSubject() { + return onClickRemoveSubject.hide(); + } + + public void setTranslations(List data) { + this.translations = data; + } + + public List getTranslations() { + return translations; + } + + class TranslationViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + + @Nullable + @BindView(R.id.translation_title) + TextView translationTitle; + + @Nullable + @BindView(R.id.translation_info) + TextView translationInfo; + + @Nullable + @BindView(R.id.left_image) + ImageView leftImage; + + @Nullable + @BindView(R.id.right_image) + ImageView rightImage; + + @Nullable + @BindView(R.id.separator_txt) + TextView separatorText; + + TranslationViewHolder(View itemView, int viewType) { + super(itemView); + ButterKnife.bind(this, itemView); + if (viewType == R.layout.translation_row) { + itemView.setOnClickListener(this); + } + } + + TextView getSeparatorText() { + return separatorText; + } + + TextView getTranslationTitle() { + return translationTitle; + } + + TextView getTranslationInfo() { + return translationInfo; + } + + ImageView getLeftImage() { + return leftImage; + } + + ImageView getRightImage() { + return rightImage; + } + + @Override + public void onClick(View v) { + TranslationItem item = (TranslationItem) translations.get(getAdapterPosition()); + if (item.exists()) { + onClickRemoveSubject.onNext(item); + } else { + onClickDownloadSubject.onNext(item); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AboutFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AboutFragment.java new file mode 100644 index 0000000000..e00a4fe936 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AboutFragment.java @@ -0,0 +1,30 @@ +package com.quran.labs.androidquran.ui.fragment; + +import com.quran.labs.androidquran.BuildConfig; +import com.quran.labs.androidquran.R; + +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; + +public class AboutFragment extends PreferenceFragment { + + private static final String[] sImagePrefKeys = + new String[] { "madaniImages", "naskhImages", "qaloonImages" }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.about); + + String flavor = BuildConfig.FLAVOR + "Images"; + PreferenceCategory parent = (PreferenceCategory) findPreference("aboutDataSources"); + for (String string : sImagePrefKeys) { + if (!string.equals(flavor)) { + Preference pref = findPreference(string); + parent.removePreference(pref); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AddTagDialog.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AddTagDialog.java new file mode 100644 index 0000000000..cf3e977fb3 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AddTagDialog.java @@ -0,0 +1,121 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.EditText; + +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.presenter.bookmark.AddTagDialogPresenter; + +import javax.inject.Inject; + +public class AddTagDialog extends DialogFragment { + + public static final String TAG = "AddTagDialog"; + + private static final String EXTRA_ID = "id"; + private static final String EXTRA_NAME = "name"; + + @Inject AddTagDialogPresenter addTagDialogPresenter; + + public static AddTagDialog newInstance(long id, String name) { + final Bundle args = new Bundle(); + args.putLong(EXTRA_ID, id); + args.putString(EXTRA_NAME, name); + final AddTagDialog dialog = new AddTagDialog(); + dialog.setArguments(args); + return dialog; + } + + public AddTagDialog() { + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + ((QuranApplication) context.getApplicationContext()).getApplicationComponent().inject(this); + } + + @Override + public void onStart() { + super.onStart(); + addTagDialogPresenter.bind(this); + } + + @Override + public void onStop() { + addTagDialogPresenter.unbind(this); + super.onStop(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Bundle args = getArguments(); + + final long id; + final String name; + if (args != null) { + id = args.getLong(EXTRA_ID, -1); + name = args.getString(EXTRA_NAME); + } else { + id = -1; + name = null; + } + + LayoutInflater inflater = getActivity().getLayoutInflater(); + @SuppressLint("InflateParams") View layout = inflater.inflate(R.layout.tag_dialog, null); + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getString(R.string.tag_dlg_title)); + + final EditText nameText = + (EditText) layout.findViewById(R.id.tag_name); + + if (id > -1) { + nameText.setText(name == null ? "" : name); + } + + builder.setView(layout); + builder.setPositiveButton(getString(R.string.dialog_ok), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String name = nameText.getText().toString(); + if (id > 0) { + addTagDialogPresenter.updateTag(new Tag(id, name)); + } else { + addTagDialogPresenter.addTag(name); + } + + dismiss(); + } + }); + + return builder.create(); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + getDialog().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + + public interface OnTagChangedListener { + + void onTagAdded(String name); + + void onTagUpdated(Tag tag); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahActionFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahActionFragment.java new file mode 100644 index 0000000000..6a048bc23b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahActionFragment.java @@ -0,0 +1,43 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.os.Bundle; +import android.support.v4.app.Fragment; + +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.ui.PagerActivity; + +public abstract class AyahActionFragment extends Fragment { + + protected SuraAyah start; + protected SuraAyah end; + private boolean justCreated; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + justCreated = true; + } + + @Override + public void onResume() { + super.onResume(); + if (justCreated) { + justCreated = false; + PagerActivity activity = (PagerActivity) getActivity(); + if (activity != null) { + start = activity.getSelectionStart(); + end = activity.getSelectionEnd(); + refreshView(); + } + } + } + + public void updateAyahSelection(SuraAyah start, SuraAyah end) { + this.start = start; + this.end = end; + refreshView(); + } + + protected abstract void refreshView(); + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahPlaybackFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahPlaybackFragment.java new file mode 100644 index 0000000000..bc5323bf1f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahPlaybackFragment.java @@ -0,0 +1,314 @@ +package com.quran.labs.androidquran.ui.fragment; + + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.service.util.AudioRequest; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.widgets.QuranSpinner; + +public class AyahPlaybackFragment extends AyahActionFragment { + private static final int REPEAT_MAX = 3; + private static final int ITEM_LAYOUT = R.layout.sherlock_spinner_item; + private static final int ITEM_DROPDOWN_LAYOUT = + R.layout.sherlock_spinner_dropdown_item; + + private SuraAyah mDecidedStart; + private SuraAyah mDecidedEnd; + private boolean mShouldEnforce; + private int mRangeRepeatCount; + private int mVerseRepeatCount; + + private Button mApplyButton; + private QuranSpinner mStartSuraSpinner; + private QuranSpinner mStartAyahSpinner; + private QuranSpinner mEndingSuraSpinner; + private QuranSpinner mEndingAyahSpinner; + private QuranSpinner mRepeatVerseSpinner; + private QuranSpinner mRepeatRangeSpinner; + private CheckBox mRestrictToRange; + private ArrayAdapter mStartAyahAdapter; + private ArrayAdapter mEndingAyahAdapter; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + final View view = inflater.inflate( + R.layout.audio_panel, container, false); + view.setOnClickListener(mOnClickListener); + + mStartSuraSpinner = (QuranSpinner) view.findViewById(R.id.start_sura_spinner); + mStartAyahSpinner = (QuranSpinner) view.findViewById(R.id.start_ayah_spinner); + mEndingSuraSpinner = (QuranSpinner) view.findViewById(R.id.end_sura_spinner); + mEndingAyahSpinner = (QuranSpinner) view.findViewById(R.id.end_ayah_spinner); + mRepeatVerseSpinner = (QuranSpinner) view + .findViewById(R.id.repeat_verse_spinner); + mRepeatRangeSpinner = (QuranSpinner) view + .findViewById(R.id.repeat_range_spinner); + mRestrictToRange = (CheckBox) view.findViewById(R.id.restrict_to_range); + mApplyButton = (Button) view.findViewById(R.id.apply); + mApplyButton.setOnClickListener(mOnClickListener); + + final Context context = getActivity(); + mStartAyahAdapter = initializeAyahSpinner(context, mStartAyahSpinner); + mEndingAyahAdapter = initializeAyahSpinner(context, mEndingAyahSpinner); + initializeSuraSpinner(context, mStartSuraSpinner, mStartAyahAdapter); + initializeSuraSpinner(context, mEndingSuraSpinner, mEndingAyahAdapter); + + final String[] repeatOptions = context.getResources() + .getStringArray(R.array.repeatValues); + final ArrayAdapter rangeAdapter = + new ArrayAdapter(context, + ITEM_LAYOUT, repeatOptions); + rangeAdapter.setDropDownViewResource( + ITEM_DROPDOWN_LAYOUT); + mRepeatRangeSpinner.setAdapter(rangeAdapter); + mRepeatRangeSpinner.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + updateEnforceBounds(position); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + final ArrayAdapter verseAdapter = + new ArrayAdapter(context, + ITEM_LAYOUT, repeatOptions); + verseAdapter.setDropDownViewResource( + ITEM_DROPDOWN_LAYOUT); + mRepeatVerseSpinner.setAdapter(verseAdapter); + return view; + } + + private View.OnClickListener mOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.apply: { + apply(); + break; + } + } + } + }; + + private void apply() { + final Context context = getActivity(); + if (context instanceof PagerActivity) { + final SuraAyah start = new SuraAyah( + mStartSuraSpinner.getSelectedItemPosition() + 1, + mStartAyahSpinner.getSelectedItemPosition() + 1); + final SuraAyah ending = new SuraAyah( + mEndingSuraSpinner.getSelectedItemPosition() + 1, + mEndingAyahSpinner.getSelectedItemPosition() + 1); + + // force the correct order + final SuraAyah currentStart; + final SuraAyah currentEnding; + if (ending.after(start)) { + currentStart = start; + currentEnding = ending; + } else { + currentStart = ending; + currentEnding = start; + } + + final int page = QuranInfo.getPageFromSuraAyah( + currentStart.sura, currentStart.ayah); + final int verseRepeat = positionToRepeat( + mRepeatVerseSpinner.getSelectedItemPosition()); + final int rangeRepeat = positionToRepeat( + mRepeatRangeSpinner.getSelectedItemPosition()); + final boolean enforceRange = mRestrictToRange.isChecked(); + + boolean updatedRange = false; + final PagerActivity pagerActivity = (PagerActivity) context; + if (!currentStart.equals(mDecidedStart) || + !currentEnding.equals(mDecidedEnd)) { + // different range or not playing, so make a new request + updatedRange = true; + if (this.start != null) { + final int origPage = mDecidedStart == null ? + this.start.getPage() : mDecidedStart.getPage(); + if (page != origPage) { + pagerActivity.highlightAyah(currentStart.sura, + currentStart.ayah, HighlightType.AUDIO); + } + } + pagerActivity.playFromAyah(currentStart, currentEnding, page, verseRepeat, + rangeRepeat, enforceRange, true); + } else if (mShouldEnforce != enforceRange || + mRangeRepeatCount != rangeRepeat || + mVerseRepeatCount != verseRepeat) { + // can just update repeat settings + if (!pagerActivity.updatePlayOptions( + rangeRepeat, verseRepeat, enforceRange)) { + // audio stopped in the process, let's start it + pagerActivity.playFromAyah(currentStart, currentEnding, page, verseRepeat, + rangeRepeat, enforceRange, true); + } + } + pagerActivity.endAyahMode(); + if (updatedRange) { + pagerActivity.toggleActionBarVisibility(true); + } + } + } + + private void initializeSuraSpinner(final Context context, + QuranSpinner spinner, final ArrayAdapter ayahAdapter) { + String[] suras = context.getResources(). + getStringArray(R.array.sura_names); + for (int i=0; i adapter = new ArrayAdapter( + context, ITEM_LAYOUT, suras); + adapter.setDropDownViewResource(ITEM_DROPDOWN_LAYOUT); + spinner.setAdapter(adapter); + + spinner.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long rowId) { + int sura = position + 1; + int ayahCount = QuranInfo.getNumAyahs(sura); + CharSequence[] ayahs = new String[ayahCount]; + for (int i = 0; i < ayahCount; i++){ + ayahs[i] = QuranUtils.getLocalizedNumber(context, (i + 1)); + } + ayahAdapter.clear(); + + for (int i=0; i arg0) { + } + }); + } + + private ArrayAdapter initializeAyahSpinner( + Context context, QuranSpinner spinner) { + final ArrayAdapter ayahAdapter = + new ArrayAdapter<>(context, ITEM_LAYOUT); + ayahAdapter.setDropDownViewResource(ITEM_DROPDOWN_LAYOUT); + spinner.setAdapter(ayahAdapter); + return ayahAdapter; + } + + private void updateAyahSpinner(QuranSpinner spinner, + ArrayAdapter adapter, + int maxAyah, int currentAyah) { + final Context context = getActivity(); + if (context != null) { + CharSequence[] ayahs = new String[maxAyah]; + for (int i = 0; i < maxAyah; i++) { + ayahs[i] = QuranUtils.getLocalizedNumber(context, (i + 1)); + } + adapter.clear(); + + for (int i = 0; i < maxAyah; i++) { + adapter.add(ayahs[i]); + } + spinner.setSelection(currentAyah - 1); + } + } + + private void updateEnforceBounds(int rangeRepeatPosition) { + if (rangeRepeatPosition > 0) { + mRestrictToRange.setChecked(true); + mRestrictToRange.setEnabled(false); + } else { + mRestrictToRange.setEnabled(true); + } + } + + private int repeatToPosition(int repeat) { + if (repeat == -1) { + return REPEAT_MAX; + } else { + return repeat; + } + } + + private int positionToRepeat(int position) { + if (position >= REPEAT_MAX) { + return -1; + } else { + return position; + } + } + + @Override + protected void refreshView() { + final Context context = getActivity(); + if (context instanceof PagerActivity && start != null && end != null) { + final AudioRequest lastRequest = + ((PagerActivity) context).getLastAudioRequest(); + final SuraAyah start; + final SuraAyah ending; + if (lastRequest != null) { + start = lastRequest.getRangeStart(); + ending = lastRequest.getRangeEnd(); + mVerseRepeatCount = lastRequest.getRepeatInfo().getRepeatCount(); + mRangeRepeatCount = lastRequest.getRangeRepeatCount(); + mShouldEnforce = lastRequest.shouldEnforceBounds(); + mDecidedStart = start; + mDecidedEnd = ending; + mApplyButton.setText(R.string.play_apply); + } else { + start = this.start; + if (this.start.equals(end)) { + final int[] pageBounds = QuranInfo.getPageBounds(start.getPage()); + ending = new SuraAyah(pageBounds[2], pageBounds[3]); + mShouldEnforce = false; + } else { + ending = end; + mShouldEnforce = true; + } + mRangeRepeatCount = 0; + mVerseRepeatCount = 0; + mDecidedStart = null; + mDecidedEnd = null; + mApplyButton.setText(R.string.play_apply_and_play); + } + + final int maxAyat = QuranInfo.getNumAyahs(start.sura); + if (maxAyat == -1) { + return; + } + + updateAyahSpinner(mStartAyahSpinner, mStartAyahAdapter, + maxAyat, start.ayah); + final int endAyat = (ending.sura == start.sura) ? maxAyat : + QuranInfo.getNumAyahs(ending.sura); + updateAyahSpinner(mEndingAyahSpinner, mEndingAyahAdapter, + endAyat, ending.ayah); + mStartSuraSpinner.setSelection(start.sura - 1); + mEndingSuraSpinner.setSelection(ending.sura - 1); + mRepeatRangeSpinner.setSelection(repeatToPosition(mRangeRepeatCount)); + mRepeatVerseSpinner.setSelection(repeatToPosition(mVerseRepeatCount)); + mRestrictToRange.setChecked(mShouldEnforce); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahTranslationFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahTranslationFragment.java new file mode 100644 index 0000000000..2eb715ee85 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/AyahTranslationFragment.java @@ -0,0 +1,154 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.data.VerseRange; +import com.quran.labs.androidquran.presenter.translation.InlineTranslationPresenter; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.util.TranslationsSpinnerAdapter; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.widgets.InlineTranslationView; +import com.quran.labs.androidquran.widgets.QuranSpinner; + +import java.util.List; + +import javax.inject.Inject; + +public class AyahTranslationFragment extends AyahActionFragment + implements InlineTranslationPresenter.TranslationScreen { + + private ProgressBar progressBar; + private InlineTranslationView translationView; + private View emptyState; + private View translationControls; + private QuranSpinner translator; + private TranslationsSpinnerAdapter translationAdapter; + private List translations; + + @Inject QuranSettings quranSettings; + @Inject InlineTranslationPresenter translationPresenter; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + ((PagerActivity) getActivity()).getPagerActivityComponent().inject(this); + } + + @Override + public View onCreateView(LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate( + R.layout.translation_panel, container, false); + + translator = (QuranSpinner) view.findViewById(R.id.translator); + translationView = (InlineTranslationView) view.findViewById(R.id.translation_view); + progressBar = (ProgressBar) view.findViewById(R.id.progress); + emptyState = view.findViewById(R.id.empty_state); + translationControls = view.findViewById(R.id.controls); + final View next = translationControls.findViewById(R.id.next_ayah); + next.setOnClickListener(onClickListener); + + final View prev = translationControls.findViewById(R.id.previous_ayah); + prev.setOnClickListener(onClickListener); + + final Button getTranslations = + (Button) view.findViewById(R.id.get_translations_button); + getTranslations.setOnClickListener(onClickListener); + return view; + } + + @Override + public void onResume() { + // currently needs to be before we call super.onResume + translationPresenter.bind(this); + super.onResume(); + } + + @Override + public void onPause() { + translationPresenter.unbind(this); + super.onPause(); + } + + private View.OnClickListener onClickListener = v -> { + final Activity activity = getActivity(); + if (activity instanceof PagerActivity) { + final PagerActivity pagerActivity = (PagerActivity) activity; + + switch (v.getId()) { + case R.id.get_translations_button: + pagerActivity.startTranslationManager(); + break; + case R.id.next_ayah: + pagerActivity.nextAyah(); + break; + case R.id.previous_ayah: + pagerActivity.previousAyah(); + break; + } + } + }; + + public void refreshView() { + if (start == null || end == null) { return; } + + final Activity activity = getActivity(); + if (activity instanceof PagerActivity) { + PagerActivity pagerActivity = (PagerActivity) activity; + if (translations == null) { + translations = pagerActivity.getTranslations(); + } + + if (translations == null || translations.size() == 0) { + progressBar.setVisibility(View.GONE); + emptyState.setVisibility(View.VISIBLE); + translationControls.setVisibility(View.GONE); + return; + } + + if (translationAdapter == null) { + translationAdapter = new TranslationsSpinnerAdapter(activity, + R.layout.translation_ab_spinner_item, + pagerActivity.getTranslationNames(), + translations, + quranSettings.getActiveTranslations(), + selectedItems -> { + quranSettings.setActiveTranslations(selectedItems); + refreshView(); + }); + translator.setAdapter(translationAdapter); + } + + if (start.equals(end)) { + translationControls.setVisibility(View.VISIBLE); + } else { + translationControls.setVisibility(View.GONE); + } + + VerseRange verseRange = new VerseRange(start.sura, start.ayah, end.sura, end.ayah); + translationPresenter.refresh(verseRange); + } + } + + @Override + public void setVerses(@NonNull String[] translations, @NonNull List verses) { + progressBar.setVisibility(View.GONE); + if (verses.size() > 0) { + emptyState.setVisibility(View.GONE); + translationView.setAyahs(translations, verses); + } else { + emptyState.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/BookmarksFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/BookmarksFragment.java new file mode 100644 index 0000000000..460d00c0d8 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/BookmarksFragment.java @@ -0,0 +1,258 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.Snackbar; +import android.support.v4.app.Fragment; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.database.BookmarksDBAdapter; +import com.quran.labs.androidquran.model.bookmark.BookmarkResult; +import com.quran.labs.androidquran.presenter.bookmark.BookmarkPresenter; +import com.quran.labs.androidquran.presenter.bookmark.BookmarksContextualModePresenter; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.ui.helpers.QuranListAdapter; +import com.quran.labs.androidquran.ui.helpers.QuranRow; + +import java.util.List; + +import javax.inject.Inject; + +public class BookmarksFragment extends Fragment implements QuranListAdapter.QuranTouchListener { + + private RecyclerView recyclerView; + private QuranListAdapter bookmarksAdapter; + + @Inject BookmarkPresenter bookmarkPresenter; + @Inject BookmarksContextualModePresenter bookmarksContextualModePresenter; + + public static BookmarksFragment newInstance(){ + return new BookmarksFragment(); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + ((QuranApplication) context.getApplicationContext()).getApplicationComponent().inject(this); + setHasOptionsMenu(true); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.quran_list, container, false); + + final Context context = getActivity(); + + recyclerView = (RecyclerView) view.findViewById(R.id.recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + recyclerView.setItemAnimator(new DefaultItemAnimator()); + + bookmarksAdapter = new QuranListAdapter(context, recyclerView, new QuranRow[0], true); + bookmarksAdapter.setQuranTouchListener(this); + recyclerView.setAdapter(bookmarksAdapter); + return view; + } + + @Override + public void onStart() { + super.onStart(); + bookmarkPresenter.bind(this); + bookmarksContextualModePresenter.bind(this); + } + + @Override + public void onStop() { + bookmarkPresenter.unbind(this); + bookmarksContextualModePresenter.unbind(this); + super.onStop(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + MenuItem sortItem = menu.findItem(R.id.sort); + if (sortItem != null) { + sortItem.setVisible(true); + sortItem.setEnabled(true); + + if (BookmarksDBAdapter.SORT_DATE_ADDED == bookmarkPresenter.getSortOrder()) { + MenuItem sortByDate = menu.findItem(R.id.sort_date); + sortByDate.setChecked(true); + } else { + MenuItem sortByLocation = menu.findItem(R.id.sort_location); + sortByLocation.setChecked(true); + } + + MenuItem groupByTags = menu.findItem(R.id.group_by_tags); + groupByTags.setChecked(bookmarkPresenter.isGroupedByTags()); + + MenuItem showRecents = menu.findItem(R.id.show_recents); + showRecents.setChecked(bookmarkPresenter.isShowingRecents()); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + switch (itemId) { + case R.id.sort_date: + bookmarkPresenter.setSortOrder(BookmarksDBAdapter.SORT_DATE_ADDED); + item.setChecked(true); + return true; + case R.id.sort_location: { + bookmarkPresenter.setSortOrder(BookmarksDBAdapter.SORT_LOCATION); + item.setChecked(true); + return true; + } + case R.id.group_by_tags: { + bookmarkPresenter.toggleGroupByTags(); + item.setChecked(bookmarkPresenter.isGroupedByTags()); + return true; + } + case R.id.show_recents: { + bookmarkPresenter.toggleShowRecents(); + item.setChecked(bookmarkPresenter.isShowingRecents()); + return true; + } + } + + return super.onOptionsItemSelected(item); + } + + public void onNewData(BookmarkResult items) { + bookmarksAdapter.setShowTags(bookmarkPresenter.shouldShowInlineTags()); + bookmarksAdapter.setElements( + items.rows.toArray(new QuranRow[items.rows.size()]), items.tagMap); + bookmarksAdapter.notifyDataSetChanged(); + } + + @Override + public void onClick(QuranRow row, int position) { + if (bookmarksContextualModePresenter.isInActionMode()) { + boolean checked = isValidSelection(row) && + !bookmarksAdapter.isItemChecked(position); + bookmarksAdapter.setItemChecked(position, checked); + bookmarksContextualModePresenter.invalidateActionMode(false); + } else { + bookmarksAdapter.setItemChecked(position, false); + handleRowClicked(getActivity(), row); + } + } + + @Override + public boolean onLongClick(QuranRow row, int position) { + if (isValidSelection(row)) { + bookmarksAdapter.setItemChecked(position, !bookmarksAdapter.isItemChecked(position)); + if (bookmarksContextualModePresenter.isInActionMode() && + bookmarksAdapter.getCheckedItems().size() == 0) { + bookmarksContextualModePresenter.finishActionMode(); + } else { + bookmarksContextualModePresenter.invalidateActionMode(true); + } + return true; + } + return false; + } + + private boolean isValidSelection(QuranRow selected) { + return selected.isBookmark() || (selected.isBookmarkHeader() && selected.tagId >= 0); + } + + private View.OnClickListener mOnUndoClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + bookmarkPresenter.cancelDeletion(); + bookmarkPresenter.requestData(true); + } + }; + + public void prepareContextualMenu(Menu menu) { + boolean[] menuVisibility = + bookmarkPresenter.getContextualOperationsForItems(bookmarksAdapter.getCheckedItems()); + menu.findItem(R.id.cab_edit_tag).setVisible(menuVisibility[0]); + menu.findItem(R.id.cab_delete).setVisible(menuVisibility[1]); + menu.findItem(R.id.cab_tag_bookmark).setVisible(menuVisibility[2]); + } + + public boolean onContextualActionClicked(int itemId) { + Activity currentActivity = getActivity(); + if (currentActivity instanceof QuranActivity) { + QuranActivity activity = (QuranActivity) currentActivity; + switch (itemId) { + case R.id.cab_delete: { + final List selected = bookmarksAdapter.getCheckedItems(); + final int size = selected.size(); + final Resources res = getResources(); + bookmarkPresenter.deleteAfterSomeTime(selected); + Snackbar snackbar = Snackbar.make(recyclerView, + res.getQuantityString(R.plurals.bookmark_tag_deleted, size, size), + BookmarkPresenter.DELAY_DELETION_DURATION_IN_MS); + snackbar.setAction(R.string.undo, mOnUndoClickListener); + snackbar.getView().setBackgroundColor(ContextCompat.getColor(activity, + R.color.snackbar_background_color)); + snackbar.show(); + return true; + } + case R.id.cab_new_tag: { + activity.addTag(); + return true; + } + case R.id.cab_edit_tag: { + handleTagEdit(activity, bookmarksAdapter.getCheckedItems()); + return true; + } + case R.id.cab_tag_bookmark: { + handleTagBookmarks(activity, bookmarksAdapter.getCheckedItems()); + return true; + } + } + } + return false; + } + + public void onCloseContextualActionMenu() { + bookmarksAdapter.uncheckAll(); + } + + private void handleRowClicked(Activity activity, QuranRow row) { + if (!row.isHeader() && activity instanceof QuranActivity) { + QuranActivity quranActivity = (QuranActivity) activity; + if (row.isAyahBookmark()) { + quranActivity.jumpToAndHighlight(row.page, row.sura, row.ayah); + } else { + quranActivity.jumpTo(row.page); + } + } + } + + private void handleTagEdit(QuranActivity activity, List selected) { + if (selected.size() == 1) { + QuranRow row = selected.get(0); + activity.editTag(row.tagId, row.text); + } + } + + private void handleTagBookmarks(QuranActivity activity, List selected) { + long[] ids = new long[selected.size()]; + for (int i = 0, selectedItems = selected.size(); i < selectedItems; i++) { + ids[i] = selected.get(i).bookmarkId; + } + activity.tagBookmarks(ids); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/JumpFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/JumpFragment.java new file mode 100644 index 0000000000..ed6c402d70 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/JumpFragment.java @@ -0,0 +1,315 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.widget.BaseAdapter; +import android.widget.EditText; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.widgets.ForceCompleteTextView; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import timber.log.Timber; + +public class JumpFragment extends DialogFragment { + public static final String TAG = "JumpFragment"; + + public JumpFragment() { + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Activity activity = getActivity(); + LayoutInflater inflater = activity.getLayoutInflater(); + @SuppressLint("InflateParams") View layout = inflater.inflate(R.layout.jump_dialog, null); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(activity.getString(R.string.menu_jump)); + + // Sura chooser + final ForceCompleteTextView suraInput = (ForceCompleteTextView) layout.findViewById( + R.id.sura_spinner); + final String[] suras = activity.getResources().getStringArray(R.array.sura_names); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < suras.length; i++) { + sb.append(QuranUtils.getLocalizedNumber(activity, (i + 1))); + sb.append(". "); + sb.append(suras[i]); + suras[i] = sb.toString(); + sb.setLength(0); + } + InfixFilterArrayAdapter suraAdapter = new InfixFilterArrayAdapter(activity, + android.R.layout.simple_spinner_dropdown_item, suras); + suraInput.setAdapter(suraAdapter); + + // Ayah chooser + final EditText ayahInput = (EditText) layout.findViewById(R.id.ayah_spinner); + + // Page chooser + final EditText pageInput = (EditText) layout.findViewById(R.id.page_number); + pageInput.setOnEditorActionListener((v, actionId, event) -> { + boolean handled = false; + if (actionId == EditorInfo.IME_ACTION_GO) { + dismiss(); + goToPage(pageInput.getText().toString()); + handled = true; + } + return handled; + }); + + suraInput.setOnItemClickListener((parent, view, position, rowId) -> { + List suraList = Arrays.asList(suras); + String enteredText = suraInput.getText().toString(); + + String suraName; + if (position >= 0) { // user selects + suraName = suraAdapter.getItem(position); + } else if (suraList.contains(enteredText)) { + suraName = enteredText; + } else if (suraAdapter.isEmpty()) { + suraName = null; // leave to the next code + } else { // maybe first initialization or invalid input + suraName = suraAdapter.getItem(0); + } + int sura = suraList.indexOf(suraName) + 1; + if (sura == 0) + sura = 1; // default to al-Fatiha + + suraInput.setTag(sura); + suraInput.setText(suras[sura - 1]); + // trigger ayah change + CharSequence ayahValue = ayahInput.getText(); + // space is intentional, to differentiate with value set by the user (delete/backspace) + ayahInput.setText(ayahValue.length() > 0 ? ayahValue : " "); + }); + + ayahInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + Context context = getActivity(); + String ayahString = s.toString(); + int ayah = parseInt(ayahString, 1); + + Object suraTag = suraInput.getTag(); + if (suraTag != null) { + int sura = (int) suraTag; + int ayahCount = QuranInfo.getNumAyahs(sura); + ayah = Math.max(1, Math.min(ayahCount, ayah)); // ensure in 1..ayahCount + int page = QuranInfo.getPageFromSuraAyah(sura, ayah); + pageInput.setHint(QuranUtils.getLocalizedNumber(context, page)); + pageInput.setText(null); + } + + ayahInput.setTag(ayah); + // seems numeric IM always use western arabic (not localized) + String correctText = String.valueOf(ayah); + // empty input means the user clears the input, we don't force to fill it, let him type + if (s.length() > 0 && !correctText.equals(ayahString)) { + s.replace(0, s.length(), correctText); + } + } + }); + + builder.setView(layout); + builder.setPositiveButton(getString(R.string.dialog_ok), (dialog, which) -> { + try { + dismiss(); + String pageStr = pageInput.getText().toString(); + if (TextUtils.isEmpty(pageStr)) { + pageStr = pageInput.getHint().toString(); + int page = Integer.parseInt(pageStr); + int selectedSura = (int) suraInput.getTag(); + int selectedAyah = (int) ayahInput.getTag(); + + if (activity instanceof QuranActivity) { + ((QuranActivity) activity).jumpToAndHighlight(page, selectedSura, selectedAyah); + } else if (activity instanceof PagerActivity) { + ((PagerActivity) activity).jumpToAndHighlight(page, selectedSura, selectedAyah); + } + } else { + goToPage(pageStr); + } + } catch (Exception e) { + Timber.d(e, "Could not jump, something went wrong..."); + } + }); + + return builder.create(); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + getDialog().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + + private void goToPage(String text) { + int page; + try { + page = Integer.parseInt(text); + } catch (NumberFormatException nfe) { + // this can happen if we are coming from IME_ACTION_GO + return; + } + // user has interacted with 'Go to page' field, so we + // need to verify if the input number is within + // the acceptable range + if (page < Constants.PAGES_FIRST || page > Constants.PAGES_LAST) { + // maybe show a toast message? + return; + } + + Activity activity = getActivity(); + if (activity instanceof QuranActivity) { + ((QuranActivity) activity).jumpTo(page); + } else if (activity instanceof PagerActivity) { + ((PagerActivity) activity).jumpTo(page); + } + } + + static int parseInt(String s, int defaultValue) { + // May be extracted to util + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * ListAdapter that supports filtering by using case-insensitive infix (substring). + */ + private static class InfixFilterArrayAdapter extends BaseAdapter implements Filterable { + // May be extracted to other package + + private List originalItems; + private List items; + private LayoutInflater inflater; + private int itemLayoutRes; + private Filter filter = new ItemFilter(); + private final Object lock = new Object(); + + InfixFilterArrayAdapter(@NonNull Context context, @LayoutRes int itemLayoutRes, + @NonNull String[] items) { + this.items = originalItems = Arrays.asList(items); + this.inflater = LayoutInflater.from(context); + this.itemLayoutRes = itemLayoutRes; + } + + @Override + public int getCount() { + return items.size(); + } + + @Override + public String getItem(int position) { + return items.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = inflater.inflate(itemLayoutRes, parent, false); + } + // As no fieldId is known/assigned, assume it is a TextView + TextView text = (TextView) convertView; + text.setText(getItem(position)); + return convertView; + } + + @Override + public Filter getFilter() { + return filter; + } + + /** + * Filter that do filtering by matching case-insensitive infix of the input. + */ + private class ItemFilter extends Filter { + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + final FilterResults results = new FilterResults(); + + // The items never change after construction, not sure if really needs to copy + final ArrayList copy; + synchronized (lock) { + copy = new ArrayList<>(originalItems); + } + + if (constraint == null || constraint.length() == 0) { + results.values = copy; + results.count = copy.size(); + } else { + final String infix = constraint.toString().toLowerCase(); + final ArrayList filteredCopy = new ArrayList<>(); + for (String i : copy) { + if (i == null) + continue; + String value = i.toLowerCase(); + if (value.contains(infix)) { + filteredCopy.add(i); + } + } + + results.values = filteredCopy; + results.count = filteredCopy.size(); + } + return results; + } + + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + //noinspection unchecked + items = (List) results.values; + if (results.count > 0) { + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + } + + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/JuzListFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/JuzListFragment.java new file mode 100644 index 0000000000..eaccaa9de5 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/JuzListFragment.java @@ -0,0 +1,144 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.ui.helpers.QuranListAdapter; +import com.quran.labs.androidquran.ui.helpers.QuranRow; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.widgets.JuzView; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.observers.DisposableSingleObserver; + +import static com.quran.labs.androidquran.data.Constants.JUZ2_COUNT; + +public class JuzListFragment extends Fragment { + private static int[] sEntryTypes = { + JuzView.TYPE_JUZ, JuzView.TYPE_QUARTER, + JuzView.TYPE_HALF, JuzView.TYPE_THREE_QUARTERS }; + + private RecyclerView mRecyclerView; + private Disposable disposable; + + public static JuzListFragment newInstance() { + return new JuzListFragment(); + } + + @Override + public View onCreateView(LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.quran_list, container, false); + + final Context context = getActivity(); + mRecyclerView = (RecyclerView) view.findViewById(R.id.recycler_view); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setLayoutManager(new LinearLayoutManager(context)); + mRecyclerView.setItemAnimator(new DefaultItemAnimator()); + + final QuranListAdapter adapter = + new QuranListAdapter(context, mRecyclerView, getJuz2List(), false); + mRecyclerView.setAdapter(adapter); + return view; + } + + @Override + public void onPause() { + if (disposable != null) { + disposable.dispose(); + } + super.onPause(); + } + + @Override + public void onResume() { + final Activity activity = getActivity(); + + if (activity instanceof QuranActivity) { + disposable = ((QuranActivity) activity).getLatestPageObservable() + .first(Constants.NO_PAGE) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(Integer recentPage) { + if (recentPage != Constants.NO_PAGE) { + int juz = QuranInfo.getJuzFromPage(recentPage); + int position = (juz - 1) * 9; + mRecyclerView.scrollToPosition(position); + } + } + + @Override + public void onError(Throwable e) { + } + }); + } + + QuranSettings settings = QuranSettings.getInstance(activity); + if (settings.isArabicNames()) { + updateScrollBarPositionHoneycomb(); + } + + super.onResume(); + } + + private void updateScrollBarPositionHoneycomb() { + mRecyclerView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_LEFT); + } + + private QuranRow[] getJuz2List() { + Activity activity = getActivity(); + Resources res = getResources(); + String[] quarters = res.getStringArray(R.array.quarter_prefix_array); + QuranRow[] elements = new QuranRow[JUZ2_COUNT * (8 + 1)]; + + int ctr = 0; + for (int i = 0; i < (8 * JUZ2_COUNT); i++) { + int[] pos = QuranInfo.QUARTERS[i]; + int page = QuranInfo.getPageFromSuraAyah(pos[0], pos[1]); + + if (i % 8 == 0) { + int juz = 1 + (i / 8); + final String juzTitle = activity.getString(R.string.juz2_description, + QuranUtils.getLocalizedNumber(activity, juz)); + final QuranRow.Builder builder = new QuranRow.Builder() + .withType(QuranRow.HEADER) + .withText(juzTitle) + .withPage(QuranInfo.JUZ_PAGE_START[juz - 1]); + elements[ctr++] = builder.build(); + } + + final String verseString = getString(R.string.quran_ayah, pos[1]); + final String metadata = + QuranInfo.getSuraName(activity, pos[0], true) + ", " + verseString; + final QuranRow.Builder builder = new QuranRow.Builder() + .withText(quarters[i]) + .withMetadata(metadata) + .withPage(page) + .withJuzType(sEntryTypes[i % 4]); + if (i % 4 == 0) { + final String overlayText = + QuranUtils.getLocalizedNumber(activity, 1 + (i / 4)); + builder.withJuzOverlayText(overlayText); + } + elements[ctr++] = builder.build(); + } + + return elements; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranAdvancedSettingsFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranAdvancedSettingsFragment.java new file mode 100644 index 0000000000..6d955a1b2e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranAdvancedSettingsFragment.java @@ -0,0 +1,426 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.support.v7.app.AlertDialog; +import android.text.TextUtils; +import android.widget.Toast; + +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.CustomEvent; +import com.quran.labs.androidquran.BuildConfig; +import com.quran.labs.androidquran.QuranAdvancedPreferenceActivity; +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.QuranImportActivity; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.model.bookmark.BookmarkImportExportModel; +import com.quran.labs.androidquran.service.util.PermissionUtil; +import com.quran.labs.androidquran.ui.preference.DataListPreference; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.util.RecordingLogTree; +import com.quran.labs.androidquran.util.StorageUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import javax.inject.Inject; + +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.observers.DisposableMaybeObserver; +import io.reactivex.observers.DisposableSingleObserver; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +public class QuranAdvancedSettingsFragment extends PreferenceFragment { + private static final int REQUEST_CODE_IMPORT = 1; + + private DataListPreference listStoragePref; + private MoveFilesAsyncTask noveFilesTask; + private List storageList; + private LoadStorageOptionsTask loadStorageOptionsTask; + private int appSize; + private boolean isPaused; + private String internalSdcardLocation; + private AlertDialog dialog; + private Context appContext; + private Disposable exportSubscription = null; + private Disposable logsSubscription; + + @Inject BookmarkImportExportModel bookmarkImportExportModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.quran_advanced_preferences); + + final Context context = getActivity(); + appContext = context.getApplicationContext(); + + // field injection + ((QuranApplication) appContext).getApplicationComponent().inject(this); + + + final Preference logsPref = findPreference(Constants.PREF_LOGS); + if (BuildConfig.DEBUG || "beta".equals(BuildConfig.BUILD_TYPE)) { + logsPref.setOnPreferenceClickListener(preference -> { + if (logsSubscription == null) { + logsSubscription = Observable.fromIterable(Timber.forest()) + .filter(tree -> tree instanceof RecordingLogTree) + .firstElement() + .map(tree -> ((RecordingLogTree) tree).getLogs()) + .map(logs -> QuranUtils.getDebugInfo(appContext) + "\n\n" + logs) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableMaybeObserver() { + @Override + public void onSuccess(String logs) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("message/rfc822"); + intent.putExtra(Intent.EXTRA_EMAIL, + new String[]{ appContext.getString(R.string.logs_email) }); + intent.putExtra(Intent.EXTRA_TEXT, logs); + intent.putExtra(Intent.EXTRA_SUBJECT, "Logs"); + startActivity(Intent.createChooser(intent, + appContext.getString(R.string.prefs_send_logs_title))); + logsSubscription = null; + } + + @Override + public void onError(Throwable e) { + } + + @Override + public void onComplete() { + } + }); + } + return true; + }); + } else { + removeAdvancePreference(logsPref); + } + + final Preference importPref = findPreference(Constants.PREF_IMPORT); + importPref.setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + String[] mimeTypes = new String[]{ "application/*", "text/*" }; + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + } + startActivityForResult(intent, REQUEST_CODE_IMPORT); + return true; + }); + + final Preference exportPref = findPreference(Constants.PREF_EXPORT); + exportPref.setOnPreferenceClickListener(preference -> { + if (exportSubscription == null) { + exportSubscription = bookmarkImportExportModel.exportBookmarksObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(Uri uri) { + Answers.getInstance().logCustom(new CustomEvent("exportData")); + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("application/json"); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.putExtra(Intent.EXTRA_STREAM, uri); + List intents = appContext.getPackageManager() + .queryIntentActivities(shareIntent, 0); + if (intents.size() > 1) { + // if only one, then that is likely Quran for Android itself, so don't show + // the chooser since it doesn't really make sense. + context.startActivity(Intent.createChooser(shareIntent, + context.getString(R.string.prefs_export_title))); + } else { + File exportedPath = new File(appContext.getExternalFilesDir(null), "backups"); + String exported = appContext.getString( + R.string.exported_data, exportedPath.toString()); + Toast.makeText(appContext, exported, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onError(Throwable e) { + exportSubscription = null; + if (isAdded()) { + Toast.makeText(context, R.string.export_data_error, Toast.LENGTH_LONG).show(); + } + } + }); + } + return true; + }); + + internalSdcardLocation = + Environment.getExternalStorageDirectory().getAbsolutePath(); + + listStoragePref = (DataListPreference) findPreference(getString(R.string.prefs_app_location)); + listStoragePref.setEnabled(false); + + try { + storageList = StorageUtils.getAllStorageLocations(context.getApplicationContext()); + } catch (Exception e) { + Timber.d(e, "Exception while trying to get storage locations"); + storageList = new ArrayList<>(); + } + + // Hide app location pref if there is no storage option + // except for the normal Environment.getExternalStorageDirectory + if (storageList == null || storageList.size() <= 1) { + Timber.d("removing advanced settings from preferences"); + hideStorageListPref(); + } else { + loadStorageOptionsTask = new LoadStorageOptionsTask(context); + loadStorageOptionsTask.execute(); + } + } + + @Override + public void onDestroy() { + if (exportSubscription != null) { + exportSubscription.dispose(); + } + + if (logsSubscription != null) { + logsSubscription.dispose(); + } + + if (dialog != null) { + dialog.dismiss(); + } + super.onDestroy(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_CODE_IMPORT && resultCode == Activity.RESULT_OK) { + Activity activity = getActivity(); + if (activity != null) { + Intent intent = new Intent(activity, QuranImportActivity.class); + intent.setData(data.getData()); + startActivity(intent); + } + } + } + + private void removeAdvancePreference(Preference preference) { + // these null checks are to fix a crash due to an NPE on 4.4.4 + if (preference != null) { + PreferenceGroup group = + (PreferenceGroup) findPreference(Constants.PREF_QURAN_SETTINGS); + if (group != null) { + group.removePreference(preference); + } + } + } + + private void hideStorageListPref() { + removeAdvancePreference(listStoragePref); + } + + private void loadStorageOptions(Context context) { + try { + if (appSize == -1) { + // sdcard is not mounted... + hideStorageListPref(); + return; + } + + listStoragePref.setLabelsAndSummaries(context, appSize, storageList); + final HashMap storageMap = + new HashMap<>(storageList.size()); + for (StorageUtils.Storage storage : storageList) { + storageMap.put(storage.getMountPoint(), storage); + } + + listStoragePref + .setOnPreferenceChangeListener((preference, newValue) -> { + final Context context1 = getActivity(); + final QuranSettings settings = QuranSettings.getInstance(context1); + + if (TextUtils.isEmpty(settings.getAppCustomLocation()) && + Environment.getExternalStorageDirectory().equals(newValue)) { + // do nothing since we're moving from empty settings to + // the default sdcard setting, which are the same, but write it. + return false; + } + + // this is called right before the preference is saved + String newLocation = (String) newValue; + StorageUtils.Storage destStorage = storageMap.get(newLocation); + String current = settings.getAppCustomLocation(); + if (appSize < destStorage.getFreeSpace()) { + if (current == null || !current.equals(newLocation)) { + if (destStorage.doesRequirePermission()) { + if (!PermissionUtil.haveWriteExternalStoragePermission(context1)) { + requestExternalStoragePermission(newLocation); + return false; + } + + // we have the permission, so fall through and handle the move + } + handleMove(newLocation); + } + } else { + Toast.makeText(context1, + getString( + R.string.prefs_no_enough_space_to_move_files), + Toast.LENGTH_LONG).show(); + } + // this says, "don't write the preference" + return false; + }); + listStoragePref.setEnabled(true); + } catch (Exception e) { + Timber.e(e, "error loading storage options"); + hideStorageListPref(); + } + } + + private void requestExternalStoragePermission(String newLocation) { + Activity activity = getActivity(); + if (activity instanceof QuranAdvancedPreferenceActivity) { + ((QuranAdvancedPreferenceActivity) activity) + .requestWriteExternalSdcardPermission(newLocation); + } + } + + + private void handleMove(String newLocation) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT || + newLocation.equals(internalSdcardLocation)) { + moveFiles(newLocation); + } else { + showKitKatConfirmation(newLocation); + } + } + + private void showKitKatConfirmation(final String newLocation) { + final Context context = getActivity(); + final AlertDialog.Builder b = new AlertDialog.Builder(context) + .setTitle(R.string.warning) + .setMessage(R.string.kitkat_external_message) + .setPositiveButton(R.string.dialog_ok, (currentDialog, which) -> { + moveFiles(newLocation); + currentDialog.dismiss(); + QuranAdvancedSettingsFragment.this.dialog = null; + }) + .setNegativeButton(R.string.cancel, (currentDialog, which) -> { + currentDialog.dismiss(); + QuranAdvancedSettingsFragment.this.dialog = null; + }); + dialog = b.create(); + dialog.show(); + } + + public void moveFiles(String newLocation) { + noveFilesTask = new MoveFilesAsyncTask(getActivity(), newLocation); + noveFilesTask.execute(); + } + + @Override + public void onResume() { + super.onResume(); + isPaused = false; + } + + @Override + public void onPause() { + isPaused = true; + super.onPause(); + } + + + private class MoveFilesAsyncTask extends AsyncTask { + + private String newLocation; + private ProgressDialog dialog; + private Context appContext; + + private MoveFilesAsyncTask(Context context, String newLocation) { + this.newLocation = newLocation; + this.appContext = context.getApplicationContext(); + } + + @Override + protected void onPreExecute() { + dialog = new ProgressDialog(getActivity()); + dialog.setMessage(appContext.getString(R.string.prefs_copying_app_files)); + dialog.setCancelable(false); + dialog.show(); + } + + @Override + protected Boolean doInBackground(Void... voids) { + return QuranFileUtils.moveAppFiles(appContext, newLocation); + } + + @Override + protected void onPostExecute(Boolean result) { + if (!isPaused) { + dialog.dismiss(); + if (result) { + QuranSettings.getInstance(appContext).setAppCustomLocation(newLocation); + if (listStoragePref != null) { + listStoragePref.setValue(newLocation); + } + } else { + Toast.makeText(appContext, + getString(R.string.prefs_err_moving_app_files), + Toast.LENGTH_LONG).show(); + } + dialog = null; + noveFilesTask = null; + } + } + } + + private class LoadStorageOptionsTask extends AsyncTask { + + private Context appContext; + + LoadStorageOptionsTask(Context context) { + this.appContext = context.getApplicationContext(); + } + + @Override + protected void onPreExecute() { + listStoragePref.setSummary(R.string.prefs_calculating_app_size); + } + + @Override + protected Void doInBackground(Void... voids) { + appSize = QuranFileUtils.getAppUsedSpace(appContext); + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (!isPaused) { + loadStorageOptions(appContext); + loadStorageOptionsTask = null; + listStoragePref.setSummary(R.string.prefs_app_location_summary); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranPageFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranPageFragment.java new file mode 100644 index 0000000000..5b034c0756 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranPageFragment.java @@ -0,0 +1,216 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.module.fragment.QuranPageModule; +import com.quran.labs.androidquran.presenter.quran.QuranPagePresenter; +import com.quran.labs.androidquran.presenter.quran.QuranPageScreen; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahImageTrackerItem; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahScrollableImageTrackerItem; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTrackerItem; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTrackerPresenter; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; +import com.quran.labs.androidquran.ui.helpers.AyahTracker; +import com.quran.labs.androidquran.ui.helpers.HighlightType; +import com.quran.labs.androidquran.ui.helpers.QuranPage; +import com.quran.labs.androidquran.ui.util.PageController; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.widgets.HighlightingImageView; +import com.quran.labs.androidquran.widgets.QuranImagePageLayout; + +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import timber.log.Timber; + +import static com.quran.labs.androidquran.ui.helpers.AyahSelectedListener.EventType; + +public class QuranPageFragment extends Fragment implements PageController, + QuranPage, QuranPageScreen, AyahTrackerPresenter.AyahInteractionHandler { + private static final String PAGE_NUMBER_EXTRA = "pageNumber"; + + private int pageNumber; + private AyahTrackerItem[] ayahTrackerItems; + + @Inject QuranSettings quranSettings; + @Inject QuranPagePresenter quranPagePresenter; + @Inject AyahTrackerPresenter ayahTrackerPresenter; + @Inject AyahSelectedListener ayahSelectedListener; + + private HighlightingImageView imageView; + private QuranImagePageLayout quranPageLayout; + private boolean ayahCoordinatesError; + + public static QuranPageFragment newInstance(int page) { + final QuranPageFragment f = new QuranPageFragment(); + final Bundle args = new Bundle(); + args.putInt(PAGE_NUMBER_EXTRA, page); + f.setArguments(args); + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onResume() { + super.onResume(); + updateView(); + } + + @Override + public View onCreateView(LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + final Context context = getActivity(); + quranPageLayout = new QuranImagePageLayout(context); + quranPageLayout.setPageController(this, pageNumber); + imageView = quranPageLayout.getImageView(); + return quranPageLayout; + } + + @Override + public void updateView() { + if (isAdded()) { + quranPageLayout.updateView(quranSettings); + if (!quranSettings.highlightBookmarks()) { + imageView.unHighlight(HighlightType.BOOKMARK); + } + quranPagePresenter.refresh(); + } + } + + @Override + public AyahTracker getAyahTracker() { + return ayahTrackerPresenter; + } + + @Override + public AyahTrackerItem[] getAyahTrackerItems() { + if (ayahTrackerItems == null) { + ayahTrackerItems = new AyahTrackerItem[]{ + quranPageLayout.canScroll() ? + new AyahScrollableImageTrackerItem(pageNumber, quranPageLayout, imageView) : + new AyahImageTrackerItem(pageNumber, imageView) + }; + } + return ayahTrackerItems; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + pageNumber = getArguments().getInt(PAGE_NUMBER_EXTRA); + ((PagerActivity) getActivity()).getPagerActivityComponent() + .quranPageComponentBuilder() + .withQuranPageModule(new QuranPageModule(pageNumber)) + .build() + .inject(this); + } + + @Override + public void onDetach() { + ayahSelectedListener = null; + super.onDetach(); + } + + @Override + public void onStart() { + super.onStart(); + quranPagePresenter.bind(this); + ayahTrackerPresenter.bind(this); + } + + @Override + public void onStop() { + quranPagePresenter.unbind(this); + ayahTrackerPresenter.unbind(this); + super.onStop(); + } + + public void cleanup() { + Timber.d("cleaning up page %d", pageNumber); + if (quranPageLayout != null) { + imageView.setImageDrawable(null); + quranPageLayout = null; + } + } + + @Override + public void setPageCoordinates(int page, RectF pageCoordinates) { + ayahTrackerPresenter.setPageBounds(page, pageCoordinates); + } + + @Override + public void setBookmarksOnPage(List bookmarks) { + ayahTrackerPresenter.setAyahBookmarks(bookmarks); + } + + @Override + public void setAyahCoordinatesData(int page, Map> coordinates) { + ayahTrackerPresenter.setAyahCoordinates(page, coordinates); + ayahCoordinatesError = false; + } + + @Override + public void setAyahCoordinatesError() { + ayahCoordinatesError = true; + } + + @Override + public void onScrollChanged(int x, int y, int oldx, int oldy) { + PagerActivity activity = (PagerActivity) getActivity(); + if (activity != null) { + activity.onQuranPageScroll(y); + } + } + + @Override + public void setPageDownloadError(@StringRes int errorMessage) { + quranPageLayout.showError(errorMessage); + quranPageLayout.setOnClickListener(v -> ayahSelectedListener.onClick(EventType.SINGLE_TAP)); + } + + @Override + public void setPageBitmap(int page, @NonNull Bitmap pageBitmap) { + imageView.setImageDrawable(new BitmapDrawable(getResources(), pageBitmap)); + } + + @Override + public void hidePageDownloadError() { + quranPageLayout.hideError(); + quranPageLayout.setOnClickListener(null); + quranPageLayout.setClickable(false); + } + + @Override + public void handleRetryClicked() { + hidePageDownloadError(); + quranPagePresenter.downloadImages(); + } + + @Override + public boolean handleTouchEvent(MotionEvent event, EventType eventType, int page) { + return isVisible() && ayahTrackerPresenter.handleTouchEvent(getActivity(), event, eventType, + page, ayahSelectedListener, ayahCoordinatesError); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.java new file mode 100644 index 0000000000..db2164c4d0 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.java @@ -0,0 +1,106 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; + +import com.quran.labs.androidquran.QuranAdvancedPreferenceActivity; +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.QuranPreferenceActivity; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.model.bookmark.BookmarkImportExportModel; +import com.quran.labs.androidquran.ui.AudioManagerActivity; +import com.quran.labs.androidquran.ui.TranslationManagerActivity; +import com.quran.labs.androidquran.util.QuranScreenInfo; + +import javax.inject.Inject; + +public class QuranSettingsFragment extends PreferenceFragment implements + SharedPreferences.OnSharedPreferenceChangeListener { + @Inject BookmarkImportExportModel bookmarkImportExportModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.quran_preferences); + + final Context context = getActivity(); + Context mAppContext = context.getApplicationContext(); + + // field injection + ((QuranApplication) mAppContext).getApplicationComponent().inject(this); + + // remove the tablet mode preference if it doesn't exist + if (!QuranScreenInfo.getOrMakeInstance(context).isTablet(context)) { + Preference tabletModePreference = + findPreference(Constants.PREF_TABLET_ENABLED); + PreferenceCategory category = + (PreferenceCategory) findPreference(Constants.PREF_DISPLAY_CATEGORY); + category.removePreference(tabletModePreference); + } + + // handle translation manager click + final Preference translationPref = findPreference(Constants.PREF_TRANSLATION_MANAGER); + translationPref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + startActivity(new Intent(getActivity(), TranslationManagerActivity.class)); + return true; + } + }); + + // handle audio manager click + final Preference audioManagerPref = findPreference(Constants.PREF_AUDIO_MANAGER); + audioManagerPref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + startActivity(new Intent(getActivity(), AudioManagerActivity.class)); + return true; + } + }); + + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + super.onPause(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, + String key) { + if (key.equals(Constants.PREF_USE_ARABIC_NAMES)) { + final Context context = getActivity(); + if (context instanceof QuranPreferenceActivity) { + ((QuranPreferenceActivity) context).restartActivity(); + } + } + } + + @Override + public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference) { + final String key = preference.getKey(); + if ("key_prefs_advanced".equals(key)) { + Intent intent = new Intent(getActivity(), QuranAdvancedPreferenceActivity.class); + startActivity(intent); + return true; + } + + return super.onPreferenceTreeClick(preferenceScreen, preference); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/SuraListFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/SuraListFragment.java new file mode 100644 index 0000000000..d353ebee80 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/SuraListFragment.java @@ -0,0 +1,135 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.ui.helpers.QuranListAdapter; +import com.quran.labs.androidquran.ui.helpers.QuranRow; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.observers.DisposableSingleObserver; + +import static com.quran.labs.androidquran.data.Constants.JUZ2_COUNT; +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST; +import static com.quran.labs.androidquran.data.Constants.SURAS_COUNT; + +public class SuraListFragment extends Fragment { + + private RecyclerView mRecyclerView; + private Disposable disposable; + + public static SuraListFragment newInstance() { + return new SuraListFragment(); + } + + @Override + public View onCreateView(LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.quran_list, container, false); + + final Context context = getActivity(); + mRecyclerView = (RecyclerView) view.findViewById(R.id.recycler_view); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setLayoutManager(new LinearLayoutManager(context)); + mRecyclerView.setItemAnimator(new DefaultItemAnimator()); + + final QuranListAdapter adapter = + new QuranListAdapter(context, mRecyclerView, getSuraList(), false); + mRecyclerView.setAdapter(adapter); + return view; + } + + @Override + public void onPause() { + if (disposable != null) { + disposable.dispose(); + } + super.onPause(); + } + + @Override + public void onResume() { + final Activity activity = getActivity(); + QuranSettings settings = QuranSettings.getInstance(activity); + if (activity instanceof QuranActivity) { + disposable = ((QuranActivity) activity).getLatestPageObservable() + .first(Constants.NO_PAGE) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(new DisposableSingleObserver() { + @Override + public void onSuccess(Integer recentPage) { + if (recentPage != Constants.NO_PAGE) { + int sura = QuranInfo.safelyGetSuraOnPage(recentPage); + int juz = QuranInfo.getJuzFromPage(recentPage); + int position = sura + juz - 1; + mRecyclerView.scrollToPosition(position); + } + } + + @Override + public void onError(Throwable e) { + } + }); + } + + if (settings.isArabicNames()) { + updateScrollBarPositionHoneycomb(); + } + + super.onResume(); + } + + private void updateScrollBarPositionHoneycomb() { + mRecyclerView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_LEFT); + } + + private QuranRow[] getSuraList() { + int next; + int pos = 0; + int sura = 1; + QuranRow[] elements = new QuranRow[SURAS_COUNT + JUZ2_COUNT]; + + Activity activity = getActivity(); + boolean wantPrefix = activity.getResources().getBoolean(R.bool.show_surat_prefix); + boolean wantTranslation = activity.getResources().getBoolean(R.bool.show_sura_names_translation); + for (int juz = 1; juz <= JUZ2_COUNT; juz++) { + final String headerTitle = activity.getString(R.string.juz2_description, + QuranUtils.getLocalizedNumber(activity, juz)); + final QuranRow.Builder headerBuilder = new QuranRow.Builder() + .withType(QuranRow.HEADER) + .withText(headerTitle) + .withPage(QuranInfo.JUZ_PAGE_START[juz - 1]); + elements[pos++] = headerBuilder.build(); + next = (juz == JUZ2_COUNT) ? PAGES_LAST + 1 : + QuranInfo.JUZ_PAGE_START[juz]; + + while ((sura <= SURAS_COUNT) && + (QuranInfo.SURA_PAGE_START[sura - 1] < next)) { + final QuranRow.Builder builder = new QuranRow.Builder() + .withText(QuranInfo.getSuraName(activity, sura, wantPrefix, wantTranslation)) + .withMetadata(QuranInfo.getSuraListMetaString(activity, sura)) + .withSura(sura) + .withPage(QuranInfo.SURA_PAGE_START[sura - 1]); + elements[pos++] = builder.build(); + sura++; + } + } + + return elements; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TabletFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TabletFragment.java new file mode 100644 index 0000000000..0d54aa6b3e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TabletFragment.java @@ -0,0 +1,289 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.module.fragment.QuranPageModule; +import com.quran.labs.androidquran.presenter.quran.QuranPagePresenter; +import com.quran.labs.androidquran.presenter.quran.QuranPageScreen; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahImageTrackerItem; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTrackerItem; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTrackerPresenter; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTranslationTrackerItem; +import com.quran.labs.androidquran.presenter.translation.TranslationPresenter; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; +import com.quran.labs.androidquran.ui.helpers.AyahTracker; +import com.quran.labs.androidquran.ui.helpers.QuranPage; +import com.quran.labs.androidquran.ui.translation.TranslationView; +import com.quran.labs.androidquran.ui.util.PageController; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.widgets.HighlightingImageView; +import com.quran.labs.androidquran.widgets.QuranImagePageLayout; +import com.quran.labs.androidquran.widgets.QuranTranslationPageLayout; +import com.quran.labs.androidquran.widgets.TabletView; + +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import dagger.Lazy; +import io.reactivex.disposables.CompositeDisposable; +import timber.log.Timber; + +import static com.quran.labs.androidquran.ui.helpers.AyahSelectedListener.EventType; + +public class TabletFragment extends Fragment + implements PageController, TranslationPresenter.TranslationScreen, + QuranPage, QuranPageScreen, AyahTrackerPresenter.AyahInteractionHandler { + private static final String FIRST_PAGE_EXTRA = "pageNumber"; + private static final String MODE_EXTRA = "mode"; + + public static class Mode { + public static final int ARABIC = 1; + public static final int TRANSLATION = 2; + } + + private int mode; + private int pageNumber; + private boolean ayahCoordinatesError; + private TabletView mainView; + private TranslationView leftTranslation; + private TranslationView rightTranslation; + private HighlightingImageView leftImageView; + private HighlightingImageView rightImageView; + private CompositeDisposable compositeDisposable = new CompositeDisposable(); + private AyahTrackerItem[] ayahTrackerItems; + + @Inject QuranSettings quranSettings; + @Inject AyahTrackerPresenter ayahTrackerPresenter; + @Inject Lazy quranPagePresenter; + @Inject Lazy translationPresenter; + @Inject AyahSelectedListener ayahSelectedListener; + + public static TabletFragment newInstance(int firstPage, int mode) { + final TabletFragment f = new TabletFragment(); + final Bundle args = new Bundle(); + args.putInt(FIRST_PAGE_EXTRA, firstPage); + args.putInt(MODE_EXTRA, mode); + f.setArguments(args); + return f; + } + + @Override + public View onCreateView(LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + final Context context = getActivity(); + mainView = new TabletView(context); + + if (mode == Mode.ARABIC) { + mainView.init(TabletView.QURAN_PAGE, TabletView.QURAN_PAGE); + leftImageView = ((QuranImagePageLayout) mainView.getLeftPage()).getImageView(); + rightImageView = ((QuranImagePageLayout) mainView.getRightPage()).getImageView(); + mainView.setPageController(this, pageNumber, pageNumber - 1); + } else if (mode == Mode.TRANSLATION) { + mainView.init(TabletView.TRANSLATION_PAGE, TabletView.TRANSLATION_PAGE); + leftTranslation = + ((QuranTranslationPageLayout) mainView.getLeftPage()).getTranslationView(); + rightTranslation = + ((QuranTranslationPageLayout) mainView.getRightPage()).getTranslationView(); + + PagerActivity pagerActivity = (PagerActivity) context; + leftTranslation.setTranslationClickedListener(v -> pagerActivity.toggleActionBar()); + rightTranslation.setTranslationClickedListener(v -> pagerActivity.toggleActionBar()); + mainView.setPageController(null, pageNumber, pageNumber - 1); + } + return mainView; + } + + @Override + public void onStart() { + super.onStart(); + ayahTrackerPresenter.bind(this); + if (mode == Mode.ARABIC) { + quranPagePresenter.get().bind(this); + } else { + translationPresenter.get().bind(this); + } + } + + @Override + public void onStop() { + ayahTrackerPresenter.unbind(this); + if (mode == Mode.ARABIC) { + quranPagePresenter.get().unbind(this); + } else { + translationPresenter.get().unbind(this); + } + super.onStop(); + } + + @Override + public void onResume() { + super.onResume(); + updateView(); + if (mode == Mode.TRANSLATION) { + rightTranslation.refresh(quranSettings); + leftTranslation.refresh(quranSettings); + } + } + + @Override + public void updateView() { + if (isAdded()) { + mainView.updateView(quranSettings); + } + } + + @Override + public AyahTracker getAyahTracker() { + return ayahTrackerPresenter; + } + + @Override + public AyahTrackerItem[] getAyahTrackerItems() { + if (ayahTrackerItems == null) { + AyahTrackerItem left; + AyahTrackerItem right; + if (mode == Mode.ARABIC) { + left = new AyahImageTrackerItem(pageNumber, false, leftImageView); + right = new AyahImageTrackerItem(pageNumber - 1, true, rightImageView); + } else if (mode == Mode.TRANSLATION) { + left = new AyahTranslationTrackerItem(pageNumber, leftTranslation); + right = new AyahTranslationTrackerItem(pageNumber - 1, rightTranslation); + } else { + return new AyahTrackerItem[0]; + } + ayahTrackerItems = new AyahTrackerItem[]{ right, left }; + } + return ayahTrackerItems; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + pageNumber = getArguments().getInt(FIRST_PAGE_EXTRA); + mode = getArguments().getInt(MODE_EXTRA, Mode.ARABIC); + + ((PagerActivity) getActivity()).getPagerActivityComponent() + .quranPageComponentBuilder() + .withQuranPageModule(new QuranPageModule(pageNumber - 1, pageNumber)) + .build() + .inject(this); + } + + @Override + public void onDetach() { + super.onDetach(); + ayahSelectedListener = null; + compositeDisposable.clear(); + } + + @Override + public void setPageDownloadError(@StringRes int errorMessage) { + mainView.showError(errorMessage); + mainView.setOnClickListener(v -> ayahSelectedListener.onClick(EventType.SINGLE_TAP)); + } + + @Override + public void setPageBitmap(int page, @NonNull Bitmap pageBitmap) { + ImageView imageView = page == pageNumber - 1 ? rightImageView : leftImageView; + imageView.setImageDrawable(new BitmapDrawable(getResources(), pageBitmap)); + } + + @Override + public void hidePageDownloadError() { + mainView.hideError(); + mainView.setOnClickListener(null); + mainView.setClickable(false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + if (mode == Mode.TRANSLATION) { + translationPresenter.get().refresh(); + } + } + + @Override + public void setVerses(int page, + @NonNull String[] translations, + @NonNull List verses) { + if (page == pageNumber) { + leftTranslation.setVerses(translations, verses); + } else if (page == pageNumber - 1) { + rightTranslation.setVerses(translations, verses); + } + } + + public void refresh() { + if (mode == Mode.TRANSLATION) { + translationPresenter.get().refresh(); + } + } + + public void cleanup() { + Timber.d("cleaning up page %d", pageNumber); + if (leftImageView != null) { + leftImageView.setImageDrawable(null); + } + + if (rightImageView != null) { + rightImageView.setImageDrawable(null); + } + } + + @Override + public void setBookmarksOnPage(List bookmarks) { + ayahTrackerPresenter.setAyahBookmarks(bookmarks); + } + + @Override + public void setPageCoordinates(int page, RectF pageCoordinates) { + ayahTrackerPresenter.setPageBounds(page, pageCoordinates); + } + + @Override + public void setAyahCoordinatesError() { + ayahCoordinatesError = true; + } + + @Override + public void setAyahCoordinatesData(int page, Map> coordinates) { + ayahTrackerPresenter.setAyahCoordinates(page, coordinates); + } + + @Override + public boolean handleTouchEvent(MotionEvent event, EventType eventType, int page) { + return isVisible() && ayahTrackerPresenter.handleTouchEvent(getActivity(), event, eventType, + page, ayahSelectedListener, ayahCoordinatesError); + } + + @Override + public void handleRetryClicked() { + hidePageDownloadError(); + quranPagePresenter.get().downloadImages(); + } + + @Override + public void onScrollChanged(int x, int y, int oldx, int oldy) { + // no-op - no image ScrollView in this mode. + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TagBookmarkDialog.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TagBookmarkDialog.java new file mode 100644 index 0000000000..5a863c11fc --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TagBookmarkDialog.java @@ -0,0 +1,234 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.FragmentActivity; +import android.support.v7.app.AlertDialog; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import com.quran.labs.androidquran.QuranApplication; +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.presenter.bookmark.TagBookmarkPresenter; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import javax.inject.Inject; + +public class TagBookmarkDialog extends DialogFragment { + + public static final String TAG = "TagBookmarkDialog"; + private static final String EXTRA_BOOKMARK_IDS = "bookmark_ids"; + + private TagsAdapter mAdapter; + @Inject TagBookmarkPresenter mTagBookmarkPresenter; + + + public static TagBookmarkDialog newInstance(long bookmarkId) { + return newInstance(new long[] { bookmarkId }); + } + + public static TagBookmarkDialog newInstance(long[] bookmarkIds) { + final Bundle args = new Bundle(); + args.putLongArray(EXTRA_BOOKMARK_IDS, bookmarkIds); + final TagBookmarkDialog dialog = new TagBookmarkDialog(); + dialog.setArguments(args); + return dialog; + } + + public TagBookmarkDialog() { + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + ((QuranApplication) context.getApplicationContext()).getApplicationComponent().inject(this); + } + + public void updateAyah(@NonNull SuraAyah suraAyah) { + mTagBookmarkPresenter.setAyahBookmarkMode(suraAyah.sura, suraAyah.ayah, suraAyah.getPage()); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Bundle args = getArguments(); + if (args != null) { + long[] bookmarkIds = args.getLongArray(EXTRA_BOOKMARK_IDS); + + if (bookmarkIds != null) { + mTagBookmarkPresenter.setBookmarksMode(bookmarkIds); + } + } + } + + private ListView createTagsListView() { + final FragmentActivity activity = getActivity(); + + mAdapter = new TagsAdapter(activity, mTagBookmarkPresenter); + + final ListView listview = new ListView(activity); + listview.setAdapter(mAdapter); + listview.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + listview.setOnItemClickListener((parent, view, position, id) -> { + Tag tag = (Tag) mAdapter.getItem(position); + boolean isChecked = mTagBookmarkPresenter.toggleTag(tag.id); + + Object viewTag = view.getTag(); + if (viewTag instanceof ViewHolder) { + ViewHolder holder = (ViewHolder) viewTag; + holder.checkBox.setChecked(isChecked); + } + }); + return listview; + } + + public void showAddTagDialog() { + Context context = getActivity(); + if (context instanceof OnBookmarkTagsUpdateListener) { + ((OnBookmarkTagsUpdateListener) context).onAddTagSelected(); + } + } + + public void setData(List tags, HashSet checkedTags) { + mAdapter.setData(tags, checkedTags); + mAdapter.notifyDataSetChanged(); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setView(createTagsListView()); + builder.setPositiveButton(R.string.dialog_ok, (dialog, which) -> { + // no-op - set in onStart to avoid closing dialog now + }); + builder.setNegativeButton(R.string.cancel, (dialog, which) -> dismiss()); + return builder.create(); + } + + @Override + public void onStart() { + super.onStart(); + mTagBookmarkPresenter.bind(this); + final Dialog dialog = getDialog(); + if (dialog instanceof AlertDialog) { + final Button positive = ((AlertDialog) dialog) + .getButton(Dialog.BUTTON_POSITIVE); + positive.setOnClickListener(v -> { + mTagBookmarkPresenter.saveChanges(); + dismiss(); + }); + } + } + + @Override + public void onStop() { + mTagBookmarkPresenter.unbind(this); + super.onStop(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // If in dialog mode, don't do anything (or else it will cause exception) + if (getShowsDialog()) { + return super.onCreateView(inflater, container, savedInstanceState); + } + // If not in dialog mode, treat as normal fragment onCreateView + return createTagsListView(); + } + + public static class TagsAdapter extends BaseAdapter { + + private LayoutInflater mInflater; + private TagBookmarkPresenter mTagBookmarkPresenter; + + private String mNewTagString; + private List mTags = new ArrayList<>(); + private HashSet mCheckedTags = new HashSet<>(); + + TagsAdapter(Context context, TagBookmarkPresenter presenter) { + mInflater = LayoutInflater.from(context); + mTagBookmarkPresenter = presenter; + mNewTagString = context.getString(R.string.new_tag); + } + + void setData(List tags, HashSet checkedTags) { + mTags = tags; + mCheckedTags = checkedTags; + } + + @Override + public int getCount() { + return mTags == null ? 0 : mTags.size(); + } + + @Override + public Object getItem(int position) { + return mTags.get(position); + } + + @Override + public long getItemId(int position) { + return mTags.get(position).id; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ViewHolder holder; + if (convertView == null) { + convertView = mInflater.inflate(R.layout.tag_row, parent, false); + holder = new ViewHolder(); + holder.checkBox = (CheckBox) convertView.findViewById(R.id.tag_checkbox); + holder.tagName = (TextView) convertView.findViewById(R.id.tag_name); + holder.addImage = (ImageView) convertView.findViewById(R.id.tag_add_image); + convertView.setTag(holder); + } + final Tag tag = (Tag) getItem(position); + holder = (ViewHolder) convertView.getTag(); + if (tag.id == -1) { + holder.addImage.setVisibility(View.VISIBLE); + holder.checkBox.setVisibility(View.GONE); + holder.tagName.setText(mNewTagString); + } else { + holder.addImage.setVisibility(View.GONE); + holder.checkBox.setVisibility(View.VISIBLE); + holder.checkBox.setChecked(mCheckedTags.contains(tag.id)); + holder.tagName.setText(tag.name); + holder.checkBox.setOnClickListener(v -> mTagBookmarkPresenter.toggleTag(tag.id)); + } + return convertView; + } + } + + static class ViewHolder { + CheckBox checkBox; + TextView tagName; + ImageView addImage; + } + + public interface OnBookmarkTagsUpdateListener { + void onAddTagSelected(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TranslationFragment.java b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TranslationFragment.java new file mode 100644 index 0000000000..828a92ce11 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/TranslationFragment.java @@ -0,0 +1,159 @@ +package com.quran.labs.androidquran.ui.fragment; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.module.fragment.QuranPageModule; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTrackerItem; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTrackerPresenter; +import com.quran.labs.androidquran.presenter.quran.ayahtracker.AyahTranslationTrackerItem; +import com.quran.labs.androidquran.presenter.translation.TranslationPresenter; +import com.quran.labs.androidquran.ui.PagerActivity; +import com.quran.labs.androidquran.ui.helpers.AyahTracker; +import com.quran.labs.androidquran.ui.helpers.QuranPage; +import com.quran.labs.androidquran.ui.translation.TranslationView; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.widgets.QuranTranslationPageLayout; + +import java.util.List; + +import javax.inject.Inject; + +public class TranslationFragment extends Fragment implements + AyahTrackerPresenter.AyahInteractionHandler, QuranPage, TranslationPresenter.TranslationScreen { + private static final String PAGE_NUMBER_EXTRA = "pageNumber"; + + private static final String SI_PAGE_NUMBER = "SI_PAGE_NUMBER"; + private static final String SI_HIGHLIGHTED_AYAH = "SI_HIGHLIGHTED_AYAH"; + + private int pageNumber; + private int highlightedAyah; + private TranslationView translationView; + private QuranTranslationPageLayout mainView; + private AyahTrackerItem[] ayahTrackerItems; + + @Inject QuranSettings quranSettings; + @Inject TranslationPresenter presenter; + @Inject AyahTrackerPresenter ayahTrackerPresenter; + + public static TranslationFragment newInstance(int page) { + final TranslationFragment f = new TranslationFragment(); + final Bundle args = new Bundle(); + args.putInt(PAGE_NUMBER_EXTRA, page); + f.setArguments(args); + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState != null) { + int page = savedInstanceState.getInt(SI_PAGE_NUMBER, -1); + if (page == pageNumber) { + int highlightedAyah = + savedInstanceState.getInt(SI_HIGHLIGHTED_AYAH, -1); + if (highlightedAyah > 0) { + this.highlightedAyah = highlightedAyah; + } + } + } + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + Context context = getActivity(); + mainView = new QuranTranslationPageLayout(context); + mainView.setPageController(null, pageNumber); + + translationView = mainView.getTranslationView(); + translationView.setTranslationClickedListener(v -> { + final Activity activity = getActivity(); + if (activity != null && activity instanceof PagerActivity) { + ((PagerActivity) getActivity()).toggleActionBar(); + } + }); + + return mainView; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + pageNumber = getArguments() != null ? getArguments().getInt(PAGE_NUMBER_EXTRA) : -1; + ((PagerActivity) getActivity()).getPagerActivityComponent() + .quranPageComponentBuilder() + .withQuranPageModule(new QuranPageModule(pageNumber)) + .build() + .inject(this); + } + + @Override + public void updateView() { + if (isAdded()) { + mainView.updateView(quranSettings); + refresh(); + } + } + + @Override + public AyahTracker getAyahTracker() { + return ayahTrackerPresenter; + } + + @Override + public AyahTrackerItem[] getAyahTrackerItems() { + if (ayahTrackerItems == null) { + ayahTrackerItems = new AyahTrackerItem[] { + new AyahTranslationTrackerItem(pageNumber, translationView) }; + } + return ayahTrackerItems; + } + + @Override + public void setVerses(int page, + @NonNull String[] translations, + @NonNull List verses) { + translationView.setVerses(translations, verses); + if (highlightedAyah > 0) { + translationView.highlightAyah(highlightedAyah); + } + } + + @Override + public void onResume() { + super.onResume(); + ayahTrackerPresenter.bind(this); + presenter.bind(this); + updateView(); + } + + @Override + public void onPause() { + ayahTrackerPresenter.unbind(this); + presenter.unbind(this); + super.onPause(); + } + + public void refresh() { + presenter.refresh(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (highlightedAyah > 0) { + outState.putInt(SI_HIGHLIGHTED_AYAH, highlightedAyah); + } + super.onSaveInstanceState(outState); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/AyahSelectedListener.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/AyahSelectedListener.java new file mode 100644 index 0000000000..94c40a4472 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/AyahSelectedListener.java @@ -0,0 +1,20 @@ +package com.quran.labs.androidquran.ui.helpers; + +import com.quran.labs.androidquran.data.SuraAyah; + +public interface AyahSelectedListener { + + enum EventType { SINGLE_TAP, LONG_PRESS, DOUBLE_TAP } + + /** Return true to receive the ayah info along with the + * click event, false to receive just the event type */ + boolean isListeningForAyahSelection(EventType eventType); + + /** Click event with ayah info and highlighter passed */ + boolean onAyahSelected(EventType eventType, + SuraAyah suraAyah, AyahTracker tracker); + + /** General click event without ayah info */ + boolean onClick(EventType eventType); + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/AyahTracker.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/AyahTracker.java new file mode 100644 index 0000000000..8af6cc5b2e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/AyahTracker.java @@ -0,0 +1,14 @@ +package com.quran.labs.androidquran.ui.helpers; + +import com.quran.labs.androidquran.widgets.AyahToolBar; + +import java.util.Set; + +public interface AyahTracker { + void highlightAyah(int sura, int ayah, HighlightType type, boolean scrollToAyah); + void highlightAyat(int page, Set ayahKeys, HighlightType type); + void unHighlightAyah(int sura, int ayah, HighlightType type); + void unHighlightAyahs(HighlightType type); + AyahToolBar.AyahToolBarPosition getToolBarPosition(int sura, int ayah, + int toolBarWidth, int toolBarHeight); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/FragmentStatePagerAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/FragmentStatePagerAdapter.java new file mode 100644 index 0000000000..f1441d8362 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/FragmentStatePagerAdapter.java @@ -0,0 +1,259 @@ +package com.quran.labs.androidquran.ui.helpers; + +/* + * Copyright (C) 2011 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. + */ + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.view.PagerAdapter; +import android.view.View; +import android.view.ViewGroup; + +import java.util.ArrayList; + +import timber.log.Timber; + +/** + * Implementation of {@link PagerAdapter} that uses a {@link Fragment} to manage each page. This + * class also handles saving and restoring of fragment's state. + * + *

This version of the pager is more useful when there are a large number of pages, working more + * like a list view. When pages are not visible to the user, their entire fragment may be + * destroyed, only keeping the saved state of that fragment. This allows the pager to hold on to + * much less memory associated with each visited page as compared to {@link FragmentPagerAdapter} at + * the cost of potentially more overhead when switching between pages. + * + * Modified for Quran + * Modifications List (for ease of merging with upstream later on): + * - added getFragmentIfExists() to return a fragment without recreating it + * - keep track of a mode, and remove all fragments when said mode changes + */ +public abstract class FragmentStatePagerAdapter extends PagerAdapter { + private static final boolean DEBUG = false; + + private final FragmentManager mFragmentManager; + private FragmentTransaction mCurTransaction = null; + + private ArrayList mSavedState = new ArrayList<>(); + private ArrayList mFragments = new ArrayList<>(); + private Fragment mCurrentPrimaryItem = null; + private String mode; + + public FragmentStatePagerAdapter(FragmentManager fm, String mode) { + mFragmentManager = fm; + this.mode = mode; + } + + /** + * Return the Fragment associated with a specified position. + */ + public abstract Fragment getItem(int position); + + @Override + public void startUpdate(ViewGroup container) { + if (container.getId() == View.NO_ID) { + throw new IllegalStateException("ViewPager with adapter " + this + + " requires a view id"); + } + } + + /** + * gets the fragment at a particular position if it exists this was added specifically for Quran + * + * @param position the position of the fragment + * @return the fragment or null + */ + public Fragment getFragmentIfExists(int position) { + if (mFragments.size() > position) { + Fragment f = mFragments.get(position); + if (f != null) { + return f; + } + } + return null; + } + + @SuppressLint("CommitTransaction") + @Override + public Object instantiateItem(ViewGroup container, int position) { + // If we already have this item instantiated, there is nothing + // to do. This can happen when we are restoring the entire pager + // from its saved state, where the fragment manager has already + // taken care of restoring the fragments we previously had instantiated. + if (mFragments.size() > position) { + Fragment f = mFragments.get(position); + if (f != null) { + return f; + } + } + + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + + Fragment fragment = getItem(position); + if (DEBUG) Timber.v("Adding item #%d: f=%s", position, fragment); + if (mSavedState.size() > position) { + Fragment.SavedState fss = mSavedState.get(position); + if (fss != null) { + fragment.setInitialSavedState(fss); + } + } + while (mFragments.size() <= position) { + mFragments.add(null); + } + fragment.setMenuVisibility(false); + fragment.setUserVisibleHint(false); + mFragments.set(position, fragment); + mCurTransaction.add(container.getId(), fragment); + + return fragment; + } + + @SuppressLint("CommitTransaction") + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + Fragment fragment = (Fragment) object; + + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + if (DEBUG) Timber.v("Removing item #%d: f=%s v=%s", + position, object, ((Fragment) object).getView()); + while (mSavedState.size() <= position) { + mSavedState.add(null); + } + + mSavedState.set(position, fragment.isAdded() + ? mFragmentManager.saveFragmentInstanceState(fragment) : null); + mFragments.set(position, null); + + mCurTransaction.remove(fragment); + } + + @Override + public void setPrimaryItem(ViewGroup container, int position, Object object) { + Fragment fragment = (Fragment) object; + if (fragment != mCurrentPrimaryItem) { + if (mCurrentPrimaryItem != null) { + mCurrentPrimaryItem.setMenuVisibility(false); + mCurrentPrimaryItem.setUserVisibleHint(false); + } + if (fragment != null) { + fragment.setMenuVisibility(true); + fragment.setUserVisibleHint(true); + } + mCurrentPrimaryItem = fragment; + } + } + + @Override + public void finishUpdate(ViewGroup container) { + if (mCurTransaction != null) { + mCurTransaction.commitNowAllowingStateLoss(); + mCurTransaction = null; + } + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return ((Fragment) object).getView() == view; + } + + @Override + public Parcelable saveState() { + Bundle state = new Bundle(); + if (mSavedState.size() > 0) { + Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()]; + mSavedState.toArray(fss); + state.putParcelableArray("states", fss); + } + for (int i = 0; i < mFragments.size(); i++) { + Fragment f = mFragments.get(i); + if (f != null && f.isAdded()) { + String key = "f" + i; + mFragmentManager.putFragment(state, key, f); + } + } + state.putString("mode", this.mode); + return state; + } + + @Override + public void restoreState(Parcelable state, ClassLoader loader) { + if (state != null) { + Bundle bundle = (Bundle) state; + bundle.setClassLoader(loader); + mSavedState.clear(); + mFragments.clear(); + + String lastMode = bundle.getString("mode", ""); + if (!mode.equals(lastMode)) { + cleanupOldFragments(bundle); + return; + } + + Parcelable[] fss = bundle.getParcelableArray("states"); + if (fss != null) { + for (Parcelable fs : fss) { + mSavedState.add((Fragment.SavedState) fs); + } + } + Iterable keys = bundle.keySet(); + for (String key : keys) { + if (key.startsWith("f")) { + int index = Integer.parseInt(key.substring(1)); + Fragment f = mFragmentManager.getFragment(bundle, key); + if (f != null) { + while (mFragments.size() <= index) { + mFragments.add(null); + } + f.setMenuVisibility(false); + mFragments.set(index, f); + } else { + Timber.w("Bad fragment at key %s", key); + } + } + } + } + } + + public void cleanupFragment(Fragment fragment) { + // no op, present for overriding + } + + private void cleanupOldFragments(Bundle bundle) { + // remove suppress once lint rule is updated to treat commitNowAllowingStateLoss as a commit + @SuppressLint("CommitTransaction") + FragmentTransaction transaction = mFragmentManager.beginTransaction(); + Iterable keys = bundle.keySet(); + for (String key : keys) { + if (key.startsWith("f")) { + Fragment f = mFragmentManager.getFragment(bundle, key); + if (f != null) { + cleanupFragment(f); + transaction.remove(f); + } + } + } + transaction.commitNowAllowingStateLoss(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/HighlightType.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/HighlightType.java new file mode 100644 index 0000000000..322b76501b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/HighlightType.java @@ -0,0 +1,53 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.content.Context; +import android.support.v4.content.ContextCompat; + +import com.quran.labs.androidquran.R; + +public class HighlightType implements Comparable { + + public static final HighlightType SELECTION = new HighlightType(1, false, R.color.selection_highlight); + public static final HighlightType AUDIO = new HighlightType(2, false, R.color.audio_highlight); + public static final HighlightType NOTE = new HighlightType(3, true, R.color.note_highlight); + public static final HighlightType BOOKMARK = new HighlightType(4, true, R.color.bookmark_highlight); + + private Long mId; + private boolean mMultipleHighlightsAllowed; + private int mColorId; + private Integer mColor = null; + + private HighlightType(long id, boolean multipleHighlightsAllowed, int colorId) { + mId = id; + mMultipleHighlightsAllowed = multipleHighlightsAllowed; + mColorId = colorId; + } + + public boolean isMultipleHighlightsAllowed() { + return mMultipleHighlightsAllowed; + } + + public int getColor(Context context) { + if (mColor == null) { + mColor = ContextCompat.getColor(context, mColorId); + } + return mColor; + } + + @Override + public int compareTo(HighlightType another) { + return mId.compareTo(another.mId); + } + + @Override + public boolean equals(Object o) { + return this == o || o != null && o.getClass() == HighlightType.class && + mId.equals(((HighlightType) o).mId); + } + + @Override + public int hashCode() { + return mId.hashCode(); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/PageDownloadListener.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/PageDownloadListener.java new file mode 100644 index 0000000000..603552d5d7 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/PageDownloadListener.java @@ -0,0 +1,9 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.graphics.drawable.BitmapDrawable; + +import com.quran.labs.androidquran.common.Response; + +public interface PageDownloadListener { + void onLoadImageResponse(BitmapDrawable drawable, Response response); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranDisplayHelper.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranDisplayHelper.java new file mode 100644 index 0000000000..fd38614588 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranDisplayHelper.java @@ -0,0 +1,128 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.LinearGradient; +import android.graphics.Point; +import android.graphics.Shader; +import android.graphics.drawable.PaintDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RectShape; +import android.os.Build; +import android.support.annotation.NonNull; +import android.view.Display; +import android.widget.Toast; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.Response; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.util.QuranFileUtils; +import com.quran.labs.androidquran.util.QuranUtils; + +import okhttp3.OkHttpClient; +import timber.log.Timber; + +public class QuranDisplayHelper { + + @NonNull + static Response getQuranPage(OkHttpClient okHttpClient, + Context context, String widthParam, int page) { + Response response; + String filename = QuranFileUtils.getPageFileName(page); + response = QuranFileUtils.getImageFromSD(context, widthParam, filename); + if (!response.isSuccessful()) { + // let's only try if an sdcard is found... otherwise, let's tell + // the user to mount their sdcard and try again. + if (response.getErrorCode() != Response.ERROR_SD_CARD_NOT_FOUND) { + Timber.d("failed to get %d with name %s from sd...", page, filename); + response = QuranFileUtils.getImageFromWeb(okHttpClient, context, filename); + } + } + return response; + } + + public static long displayMarkerPopup(Context context, int page, + long lastPopupTime) { + if (System.currentTimeMillis() - lastPopupTime < 3000) { + return lastPopupTime; + } + int rub3 = QuranInfo.getRub3FromPage(page); + if (rub3 == -1) { + return lastPopupTime; + } + int hizb = (rub3 / 4) + 1; + StringBuilder sb = new StringBuilder(); + + if (rub3 % 8 == 0) { + sb.append(context.getString(R.string.quran_juz2)).append(' ') + .append(QuranUtils.getLocalizedNumber(context, + (hizb / 2) + 1)); + } else { + int remainder = rub3 % 4; + if (remainder == 1) { + sb.append(context.getString(R.string.quran_rob3)).append(' '); + } else if (remainder == 2) { + sb.append(context.getString(R.string.quran_nos)).append(' '); + } else if (remainder == 3) { + sb.append(context.getString(R.string.quran_talt_arb3)).append(' '); + } + sb.append(context.getString(R.string.quran_hizb)).append(' ') + .append(QuranUtils.getLocalizedNumber(context, hizb)); + } + + String result = sb.toString(); + Toast.makeText(context, result, Toast.LENGTH_SHORT).show(); + return System.currentTimeMillis(); + } + + // same logic used in displayMarkerPopup method + public static String displayRub3(Context context, int page){ + int rub3 = QuranInfo.getRub3FromPage(page); + if (rub3 == -1) { + return ""; + } + int hizb = (rub3 / 4) + 1; + StringBuilder sb = new StringBuilder(); + sb.append(" , "); + int remainder = rub3 % 4; + if (remainder == 1) { + sb.append(context.getString(R.string.quran_rob3)).append(' '); + } else if (remainder == 2) { + sb.append(context.getString(R.string.quran_nos)).append(' '); + } else if (remainder == 3) { + sb.append(context.getString(R.string.quran_talt_arb3)).append(' '); + } + sb.append(context.getString(R.string.quran_hizb)).append(' ') + .append(QuranUtils.getLocalizedNumber(context, hizb)); + + return sb.toString(); + } + + public static PaintDrawable getPaintDrawable(int startX, int endX) { + PaintDrawable drawable = new PaintDrawable(); + drawable.setShape(new RectShape()); + drawable.setShaderFactory(getShaderFactory(startX, endX)); + return drawable; + } + + private static ShapeDrawable.ShaderFactory getShaderFactory(final int startX, final int endX) { + return new ShapeDrawable.ShaderFactory() { + + @Override + public Shader resize(int width, int height) { + return new LinearGradient(startX, 0, endX, 0, + new int[]{0xFFDCDAD5, 0xFFFDFDF4, + 0xFFFFFFFF, 0xFFFDFBEF}, + new float[]{0, 0.18f, 0.48f, 1}, + Shader.TileMode.REPEAT); + } + }; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public static int getWidthKitKat(Display display) { + Point point = new Point(); + display.getRealSize(point); + return point.x; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranListAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranListAdapter.java new file mode 100644 index 0000000000..9a8f4f50e4 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranListAdapter.java @@ -0,0 +1,275 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.support.v7.widget.RecyclerView; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.ui.QuranActivity; +import com.quran.labs.androidquran.util.QuranUtils; +import com.quran.labs.androidquran.widgets.JuzView; +import com.quran.labs.androidquran.widgets.TagsViewGroup; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class QuranListAdapter extends + RecyclerView.Adapter + implements View.OnClickListener, View.OnLongClickListener { + + private Context context; + private LayoutInflater inflater; + private QuranRow[] elements; + private RecyclerView recyclerView; + private SparseBooleanArray checkedState; + private QuranTouchListener touchListener; + private Map tagMap; + private boolean showTags; + private boolean isEditable; + + public QuranListAdapter(Context context, RecyclerView recyclerView, + QuranRow[] items, boolean isEditable) { + inflater = LayoutInflater.from(context); + this.recyclerView = recyclerView; + elements = items; + this.context = context; + checkedState = new SparseBooleanArray(); + this.isEditable = isEditable; + } + + public long getItemId(int position) { + return position; + } + + @Override + public int getItemCount() { + return elements.length; + } + + private QuranRow getQuranRow(int position) { + return elements[position]; + } + + public boolean isItemChecked(int position) { + return checkedState.get(position); + } + + public void setItemChecked(int position, boolean checked) { + checkedState.put(position, checked); + notifyItemChanged(position); + } + + public List getCheckedItems() { + final List result = new ArrayList<>(); + final int count = checkedState.size(); + final int elements = getItemCount(); + for (int i = 0; i < count; i++) { + final int key = checkedState.keyAt(i); + // TODO: figure out why sometimes elements > key + if (checkedState.get(key) && elements > key) { + result.add(getQuranRow(key)); + } + } + return result; + } + + public void uncheckAll() { + checkedState.clear(); + notifyDataSetChanged(); + } + + public void setElements(QuranRow[] elements, Map tagMap) { + this.elements = elements; + this.tagMap = tagMap; + } + + public void setShowTags(boolean showTags) { + this.showTags = showTags; + } + + private void bindRow(HeaderHolder vh, int position) { + ViewHolder holder = (ViewHolder) vh; + + final QuranRow item = elements[position]; + bindHeader(vh, position); + holder.number.setText( + QuranUtils.getLocalizedNumber(context, item.sura)); + + holder.metadata.setVisibility(View.VISIBLE); + holder.metadata.setText(item.metadata); + holder.tags.setVisibility(View.GONE); + + if (item.juzType != null) { + holder.image.setImageDrawable( + new JuzView(context, item.juzType, item.juzOverlayText)); + holder.image.setVisibility(View.VISIBLE); + holder.number.setVisibility(View.GONE); + } else if (item.imageResource == null) { + holder.number.setVisibility(View.VISIBLE); + holder.image.setVisibility(View.GONE); + } else { + holder.image.setImageResource(item.imageResource); + if (item.imageFilterColor == null) { + holder.image.setColorFilter(null); + } else { + holder.image.setColorFilter( + item.imageFilterColor, PorterDuff.Mode.SRC_ATOP); + } + holder.image.setVisibility(View.VISIBLE); + holder.number.setVisibility(View.GONE); + + List tags = new ArrayList<>(); + Bookmark bookmark = item.bookmark; + if (bookmark != null && !bookmark.tags.isEmpty() && showTags) { + for (int i = 0, bookmarkTags = bookmark.tags.size(); i < bookmarkTags; i++) { + Long tagId = bookmark.tags.get(i); + Tag tag = tagMap.get(tagId); + if (tag != null) { + tags.add(tag); + } + } + } + + if (tags.isEmpty()) { + holder.tags.setVisibility(View.GONE); + } else { + holder.tags.setTags(tags); + holder.tags.setVisibility(View.VISIBLE); + } + } + } + + private void bindHeader(HeaderHolder holder, int pos) { + final QuranRow item = elements[pos]; + holder.title.setText(item.text); + if (item.page == 0) { + holder.pageNumber.setVisibility(View.GONE); + } else { + holder.pageNumber.setVisibility(View.VISIBLE); + holder.pageNumber.setText( + QuranUtils.getLocalizedNumber(context, item.page)); + } + holder.setChecked(isItemChecked(pos)); + + final boolean enabled = isEnabled(pos); + holder.setEnabled(enabled); + } + + @Override + public HeaderHolder onCreateViewHolder(ViewGroup parent, int viewType) { + if (viewType == 0) { + final View view = inflater.inflate(R.layout.index_header_row, parent, false); + return new HeaderHolder(view); + } else { + final View view = inflater.inflate(R.layout.index_sura_row, parent, false); + return new ViewHolder(view); + } + } + + @Override + public void onBindViewHolder(HeaderHolder viewHolder, int position) { + final int type = getItemViewType(position); + if (type == 0) { + bindHeader(viewHolder, position); + } else { + bindRow(viewHolder, position); + } + } + + @Override + public int getItemViewType(int position) { + return elements[position].isHeader() ? 0 : 1; + } + + private boolean isEnabled(int position) { + final QuranRow selected = elements[position]; + return !isEditable || // anything in surahs or juzs + selected.isBookmark() || // actual bookmarks + selected.rowType == QuranRow.NONE || // the actual "current page" + selected.isBookmarkHeader(); // tags + } + + public void setQuranTouchListener(QuranTouchListener listener) { + touchListener = listener; + } + + @Override + public void onClick(View v) { + final int position = recyclerView.getChildAdapterPosition(v); + if (position != RecyclerView.NO_POSITION) { + final QuranRow element = elements[position]; + if (touchListener == null) { + ((QuranActivity) context).jumpTo(element.page); + } else { + touchListener.onClick(element, position); + } + } + } + + @Override + public boolean onLongClick(View v) { + if (touchListener != null) { + final int position = recyclerView.getChildAdapterPosition(v); + if (position != RecyclerView.NO_POSITION) { + return touchListener.onLongClick(elements[position], position); + } + } + return false; + } + + class HeaderHolder extends RecyclerView.ViewHolder { + + TextView title; + TextView pageNumber; + View view; + + HeaderHolder(View itemView) { + super(itemView); + view = itemView; + title = (TextView) itemView.findViewById(R.id.title); + pageNumber = (TextView) itemView.findViewById(R.id.pageNumber); + } + + void setEnabled(boolean enabled) { + view.setEnabled(enabled); + itemView.setOnClickListener(enabled ? QuranListAdapter.this : null); + itemView.setOnLongClickListener(isEditable && enabled ? QuranListAdapter.this : null); + } + + void setChecked(boolean checked) { + view.setActivated(checked); + } + } + + private class ViewHolder extends HeaderHolder { + + TextView number; + TextView metadata; + ImageView image; + TagsViewGroup tags; + + ViewHolder(View itemView) { + super(itemView); + metadata = (TextView) itemView.findViewById(R.id.metadata); + number = (TextView) itemView.findViewById(R.id.suraNumber); + image = (ImageView) itemView.findViewById(R.id.rowIcon); + tags = (TagsViewGroup) itemView.findViewById(R.id.tags); + } + } + + public interface QuranTouchListener { + + void onClick(QuranRow row, int position); + + boolean onLongClick(QuranRow row, int position); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPage.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPage.java new file mode 100644 index 0000000000..952046ca8b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPage.java @@ -0,0 +1,6 @@ +package com.quran.labs.androidquran.ui.helpers; + +public interface QuranPage { + void updateView(); + AyahTracker getAyahTracker(); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.java new file mode 100644 index 0000000000..28bc2047c9 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageAdapter.java @@ -0,0 +1,113 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.ui.fragment.QuranPageFragment; +import com.quran.labs.androidquran.ui.fragment.TabletFragment; +import com.quran.labs.androidquran.ui.fragment.TranslationFragment; + +import timber.log.Timber; + +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST; +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST_DUAL; + +public class QuranPageAdapter extends FragmentStatePagerAdapter { + + private boolean mIsShowingTranslation = false; + private boolean mIsDualPages = false; + + public QuranPageAdapter(FragmentManager fm, boolean dualPages, + boolean isShowingTranslation) { + super(fm, dualPages ? "dualPages" : "singlePage"); + mIsDualPages = dualPages; + mIsShowingTranslation = isShowingTranslation; + } + + public void setTranslationMode() { + if (!mIsShowingTranslation) { + mIsShowingTranslation = true; + notifyDataSetChanged(); + } + } + + public void setQuranMode() { + if (mIsShowingTranslation) { + mIsShowingTranslation = false; + notifyDataSetChanged(); + } + } + + public boolean getIsShowingTranslation() { + return mIsShowingTranslation; + } + + @Override + public int getItemPosition(Object object) { + /* when the ViewPager gets a notifyDataSetChanged (or invalidated), + * it goes through its set of saved views and runs this method on + * each one to figure out whether or not it should remove the view + * or not. the default implementation returns POSITION_UNCHANGED, + * which means that "this page is as is." + * + * as noted in http://stackoverflow.com/questions/7263291 in one + * of the answers, if you're just updating your view (changing a + * field's value, etc), this is highly inefficient (because you + * recreate the view for nothing). + * + * in our case, however, this is the right thing to do since we + * change the fragment completely when we notifyDataSetChanged. + */ + return POSITION_NONE; + } + + @Override + public int getCount() { + return mIsDualPages ? PAGES_LAST_DUAL : PAGES_LAST; + } + + @Override + public Fragment getItem(int position) { + int page = QuranInfo.getPageFromPos(position, mIsDualPages); + Timber.d("getting page: %d", page); + if (mIsDualPages) { + return TabletFragment.newInstance(page, + mIsShowingTranslation ? TabletFragment.Mode.TRANSLATION : + TabletFragment.Mode.ARABIC); + } else if (mIsShowingTranslation) { + return TranslationFragment.newInstance(page); + } else { + return QuranPageFragment.newInstance(page); + } + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + Fragment f = (Fragment) object; + Timber.d("destroying item: %d, %s", position, f); + cleanupFragment(f); + super.destroyItem(container, position, object); + } + + @Override + public void cleanupFragment(Fragment f) { + if (f instanceof QuranPageFragment) { + ((QuranPageFragment) f).cleanup(); + } else if (f instanceof TabletFragment) { + ((TabletFragment) f).cleanup(); + } + } + + public QuranPage getFragmentIfExistsForPage(int page) { + if (page < Constants.PAGES_FIRST || PAGES_LAST < page) { + return null; + } + int position = QuranInfo.getPosFromPage(page, mIsDualPages); + Fragment fragment = getFragmentIfExists(position); + return fragment instanceof QuranPage && fragment.isAdded() ? (QuranPage) fragment : null; + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageWorker.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageWorker.java new file mode 100644 index 0000000000..14960d5ab2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranPageWorker.java @@ -0,0 +1,76 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.content.Context; +import android.util.Log; + +import com.crashlytics.android.Crashlytics; +import com.quran.labs.androidquran.common.Response; +import com.quran.labs.androidquran.di.ActivityScope; +import com.quran.labs.androidquran.util.QuranScreenInfo; + +import javax.inject.Inject; + +import io.reactivex.Observable; +import io.reactivex.schedulers.Schedulers; +import okhttp3.OkHttpClient; + +@ActivityScope +public class QuranPageWorker { + private static final String TAG = "QuranPageWorker"; + + private final Context appContext; + private final OkHttpClient okHttpClient; + private final String imageWidth; + + @Inject + QuranPageWorker(Context context, OkHttpClient okHttpClient, String imageWidth) { + this.appContext = context; + this.okHttpClient = okHttpClient; + this.imageWidth = imageWidth; + } + + private Response downloadImage(int pageNumber) { + Response response = null; + OutOfMemoryError oom = null; + + try { + response = QuranDisplayHelper.getQuranPage(okHttpClient, appContext, imageWidth, pageNumber); + } catch (OutOfMemoryError me){ + Crashlytics.log(Log.WARN, TAG, + "out of memory exception loading page " + pageNumber + ", " + imageWidth); + oom = me; + } + + if (response == null || + (response.getBitmap() == null && + response.getErrorCode() != Response.ERROR_SD_CARD_NOT_FOUND)){ + if (QuranScreenInfo.getInstance().isTablet(appContext)){ + Crashlytics.log(Log.WARN, TAG, "tablet got bitmap null, trying alternate width..."); + String param = QuranScreenInfo.getInstance().getWidthParam(); + if (param.equals(imageWidth)){ + param = QuranScreenInfo.getInstance().getTabletWidthParam(); + } + response = QuranDisplayHelper.getQuranPage(okHttpClient, appContext, param, pageNumber); + if (response.getBitmap() == null){ + Crashlytics.log(Log.WARN, TAG, + "bitmap still null, giving up... [" + response.getErrorCode() + "]"); + } + } + Crashlytics.log(Log.WARN, TAG, "got response back as null... [" + + (response == null ? "" : response.getErrorCode())); + } + + if ((response == null || response.getBitmap() == null) && oom != null) { + throw oom; + } + + response.setPageData(pageNumber); + return response; + } + + public Observable loadPages(Integer... pages) { + return Observable.fromArray(pages) + .flatMap(page -> Observable.fromCallable(() -> downloadImage(page))) + .subscribeOn(Schedulers.io()); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranRow.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranRow.java new file mode 100644 index 0000000000..d255c495da --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranRow.java @@ -0,0 +1,147 @@ +package com.quran.labs.androidquran.ui.helpers; + +import com.quran.labs.androidquran.dao.Bookmark; + +public class QuranRow { + + // Row Types + public static final int NONE = 0; + public static final int HEADER = 1; + public static final int PAGE_BOOKMARK = 2; + public static final int AYAH_BOOKMARK = 3; + public static final int BOOKMARK_HEADER = 4; + + public int sura; + public int ayah; + public int page; + public String text; + public String metadata; + public int rowType; + public Integer imageResource; + public Integer imageFilterColor; + public Integer juzType; + public String juzOverlayText; + + // For Bookmarks + public long tagId; + public long bookmarkId; + public Bookmark bookmark; + + public static class Builder { + + private String mText; + private String mMetadata; + private int mSura; + private int mAyah; + private int mPage; + private int mRowType = NONE; + private Integer mImageResource; + private Integer mJuzType; + private long mTagId = -1; + private long mBookmarkId = -1; + private String mJuzOverlayText; + private Integer mImageFilterColor; + private Bookmark mBookmark; + + public Builder withType(int type) { + mRowType = type; + return this; + } + + public Builder withText(String text) { + mText = text; + return this; + } + + public Builder withMetadata(String metadata) { + mMetadata = metadata; + return this; + } + + public Builder withBookmark(Bookmark bookmark) { + if (!bookmark.isPageBookmark()) { + mSura = bookmark.sura; + mAyah = bookmark.ayah; + } + mPage = bookmark.page; + mBookmark = bookmark; + mBookmarkId = bookmark.id; + return this; + } + + public Builder withSura(int sura) { + mSura = sura; + return this; + } + + public Builder withPage(int page) { + mPage = page; + return this; + } + + public Builder withImageResource(int resId) { + mImageResource = resId; + return this; + } + + public Builder withImageOverlayColor(int color) { + mImageFilterColor = color; + return this; + } + + public Builder withJuzType(int juzType) { + mJuzType = juzType; + return this; + } + + public Builder withJuzOverlayText(String text) { + mJuzOverlayText = text; + return this; + } + + public Builder withTagId(long id) { + mTagId = id; + return this; + } + + public QuranRow build() { + return new QuranRow(mText, mMetadata, mRowType, mSura, + mAyah, mPage, mImageResource, mImageFilterColor, mJuzType, + mJuzOverlayText, mBookmarkId, mTagId, mBookmark); + } + } + + private QuranRow(String text, String metadata, int rowType, + int sura, int ayah, int page, Integer imageResource, Integer filterColor, + Integer juzType, String juzOverlayText, long bookmarkId, long tagId, Bookmark bookmark) { + this.text = text; + this.rowType = rowType; + this.sura = sura; + this.ayah = ayah; + this.page = page; + this.metadata = metadata; + this.imageResource = imageResource; + this.imageFilterColor = filterColor; + this.juzType = juzType; + this.juzOverlayText = juzOverlayText; + this.tagId = tagId; + this.bookmarkId = bookmarkId; + this.bookmark = bookmark; + } + + public boolean isHeader() { + return rowType == HEADER || rowType == BOOKMARK_HEADER; + } + + public boolean isBookmarkHeader() { + return rowType == BOOKMARK_HEADER; + } + + public boolean isBookmark() { + return rowType == PAGE_BOOKMARK || rowType == AYAH_BOOKMARK; + } + + public boolean isAyahBookmark() { + return rowType == AYAH_BOOKMARK; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranRowFactory.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranRowFactory.java new file mode 100644 index 0000000000..099e21f487 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/QuranRowFactory.java @@ -0,0 +1,97 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.content.Context; +import android.support.v4.content.ContextCompat; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.Bookmark; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.data.QuranInfo; + +public class QuranRowFactory { + + public static QuranRow fromRecentPageHeader(Context context, int count) { + return new QuranRow.Builder() + .withText(context.getResources().getQuantityString(R.plurals.plural_recent_pages, count)) + .withType(QuranRow.HEADER) + .build(); + } + + public static QuranRow fromPageBookmarksHeader(Context context) { + return new QuranRow.Builder() + .withText(context.getString(R.string.menu_bookmarks_page)) + .withType(QuranRow.HEADER).build(); + } + + public static QuranRow fromAyahBookmarksHeader(Context context) { + return new QuranRow.Builder() + .withText(context.getString(R.string.menu_bookmarks_ayah)) + .withType(QuranRow.HEADER).build(); + } + + public static QuranRow fromCurrentPage(Context context, int page) { + return new QuranRow.Builder() + .withText(QuranInfo.getSuraNameString(context, page)) + .withMetadata(QuranInfo.getPageSubtitle(context, page)) + .withSura(QuranInfo.safelyGetSuraOnPage(page)) + .withPage(page) + .withImageResource(R.drawable.bookmark_currentpage).build(); + } + + public static QuranRow fromBookmark(Context context, Bookmark bookmark) { + return fromBookmark(context, bookmark, null); + } + + public static QuranRow fromBookmark(Context context, Bookmark bookmark, Long tagId) { + final QuranRow.Builder builder = new QuranRow.Builder(); + + if (bookmark.isPageBookmark()) { + final int sura = QuranInfo.getSuraNumberFromPage(bookmark.page); + builder.withText(QuranInfo.getSuraNameString(context, bookmark.page)) + .withMetadata(QuranInfo.getPageSubtitle(context, bookmark.page)) + .withType(QuranRow.PAGE_BOOKMARK) + .withBookmark(bookmark) + .withSura(sura) + .withImageResource(R.drawable.ic_favorite); + } else { + String ayahText = bookmark.getAyahText(); + + final String title; + final String metadata; + if (ayahText == null) { + title = QuranInfo.getAyahString(bookmark.sura, bookmark.ayah, context); + metadata = QuranInfo.getPageSubtitle(context, bookmark.page); + } else { + title = ayahText; + metadata = QuranInfo.getAyahMetadata(bookmark.sura, bookmark.ayah, bookmark.page, context); + } + + builder.withText(title) + .withMetadata(metadata) + .withType(QuranRow.AYAH_BOOKMARK) + .withBookmark(bookmark) + .withImageResource(R.drawable.ic_favorite) + .withImageOverlayColor(ContextCompat.getColor(context, R.color.ayah_bookmark_color)); + } + + if (tagId != null) { + builder.withTagId(tagId); + } + return builder.build(); + } + + public static QuranRow fromTag(Tag tag) { + return new QuranRow.Builder() + .withType(QuranRow.BOOKMARK_HEADER) + .withText(tag.name) + .withTagId(tag.id) + .build(); + } + + public static QuranRow fromNotTaggedHeader(Context context) { + return new QuranRow.Builder() + .withType(QuranRow.BOOKMARK_HEADER) + .withText(context.getString(R.string.not_tagged)) + .build(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/SlidingPagerAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/SlidingPagerAdapter.java new file mode 100644 index 0000000000..0b342d8111 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/SlidingPagerAdapter.java @@ -0,0 +1,60 @@ +package com.quran.labs.androidquran.ui.helpers; + +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.ui.fragment.AyahPlaybackFragment; +import com.quran.labs.androidquran.ui.fragment.AyahTranslationFragment; +import com.quran.labs.androidquran.ui.fragment.TagBookmarkDialog; +import com.quran.labs.androidquran.widgets.IconPageIndicator; + +public class SlidingPagerAdapter extends FragmentStatePagerAdapter implements + IconPageIndicator.IconPagerAdapter { + + public static final int TAG_PAGE = 0; + public static final int TRANSLATION_PAGE = 1; + public static final int AUDIO_PAGE = 2; + public static final int[] PAGES = { + TAG_PAGE, TRANSLATION_PAGE, AUDIO_PAGE + }; + public static final int[] PAGE_ICONS = { + R.drawable.ic_tag, R.drawable.ic_translation, R.drawable.ic_play + }; + + private boolean mIsRtl; + + public SlidingPagerAdapter(FragmentManager fm, boolean isRtl) { + super(fm, "sliding"); + mIsRtl = isRtl; + } + + @Override + public int getCount() { + return PAGES.length; + } + + public int getPagePosition(int page) { + return mIsRtl ? (PAGES.length - 1) - page : page; + } + + @Override + public Fragment getItem(int position) { + final int pos = getPagePosition(position); + switch (pos) { + case TAG_PAGE: + return new TagBookmarkDialog(); + case TRANSLATION_PAGE: + return new AyahTranslationFragment(); + case AUDIO_PAGE: + return new AyahPlaybackFragment(); + } + return null; + } + + @Override + public int getIconResId(int index) { + return PAGE_ICONS[getPagePosition(index)]; + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/helpers/UthmaniSpan.java b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/UthmaniSpan.java new file mode 100644 index 0000000000..5b1d4bb13f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/helpers/UthmaniSpan.java @@ -0,0 +1,26 @@ +package com.quran.labs.androidquran.ui.helpers; + +import com.quran.labs.androidquran.ui.util.TypefaceManager; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +public class UthmaniSpan extends MetricAffectingSpan { + private Typeface mTypeface; + + public UthmaniSpan(Context context) { + mTypeface = TypefaceManager.getUthmaniTypeface(context); + } + + @Override + public void updateDrawState(TextPaint ds) { + ds.setTypeface(mTypeface); + } + + @Override + public void updateMeasureState(TextPaint paint) { + paint.setTypeface(mTypeface); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/preference/DataListPreference.java b/app/src/main/java/com/quran/labs/androidquran/ui/preference/DataListPreference.java new file mode 100644 index 0000000000..4f03997a97 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/preference/DataListPreference.java @@ -0,0 +1,136 @@ +package com.quran.labs.androidquran.ui.preference; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.StorageUtils; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.os.Build; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.CheckedTextView; +import android.widget.ListAdapter; +import android.widget.TextView; + +import java.util.List; + +/** + * Here we show storage title and free space amount (currently, in MB) in summary. + * However, `ListPreference` does not provide summary text out of the box, and thus + * we use a custom layout with two `TextView`s, for a title and a summary, + * and a `CheckedTextView` for a radio-button. + * We remove the `CheckedTextView`'s title during runtime and use one of the + * `TextView`s instead to represent the title. + * + * Also, we extend from `QuranListPreference` in order not to duplicate code for + * setting dialog title color. + */ +public class DataListPreference extends QuranListPreference { + private CharSequence[] mDescriptions; + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public DataListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public DataListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public DataListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public DataListPreference(Context context) { + super(context); + } + + public void setLabelsAndSummaries(Context context, int appSize, + List storageList) { + String summary = context.getString(R.string.prefs_app_location_summary) + "\n" + + context.getString(R.string.prefs_app_size) + " " + + context.getString(R.string.prefs_megabytes_int, appSize); + setSummary(summary); + + CharSequence[] values = new CharSequence[storageList.size()]; + CharSequence[] displayNames = new CharSequence[storageList.size()]; + mDescriptions = new CharSequence[storageList.size()]; + StorageUtils.Storage storage; + for (int i = 0; i < storageList.size(); i++) { + storage = storageList.get(i); + values[i] = storage.getMountPoint(); + displayNames[i] = storage.getLabel(); + mDescriptions[i] = storage.getMountPoint() + " " + + context.getString(R.string.prefs_megabytes_int, storage.getFreeSpace()); + } + setEntries(displayNames); + setEntryValues(values); + final QuranSettings settings = QuranSettings.getInstance(context); + String current = settings.getAppCustomLocation(); + if (TextUtils.isEmpty(current)) { + current = values[0].toString(); + } + setValue(current); + } + + @Override + protected void onPrepareDialogBuilder(@NonNull AlertDialog.Builder builder) { + int selectedIndex = findIndexOfValue(getValue()); + ListAdapter adapter = new StorageArrayAdapter(getContext(), R.layout.data_storage_location_item, + getEntries(), selectedIndex, mDescriptions); + builder.setAdapter(adapter, this); + super.onPrepareDialogBuilder(builder); + } + + static class ViewHolder { + public TextView titleTextView; + public TextView summaryTextView; + public CheckedTextView checkedTextView; + } + + public class StorageArrayAdapter extends ArrayAdapter { + private int mSelectedIndex = 0; + private CharSequence[] mFreeSpaces; + + public StorageArrayAdapter(Context context, int textViewResourceId, CharSequence[] objects, + int selectedIndex, CharSequence[] freeSpaces) { + super(context, textViewResourceId, objects); + mSelectedIndex = selectedIndex; + mFreeSpaces = freeSpaces; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ViewHolder holder; + if (convertView == null) { + LayoutInflater inflater = ((Activity) getContext()).getLayoutInflater(); + convertView = inflater.inflate(R.layout.data_storage_location_item, parent, false); + + holder = new ViewHolder(); + holder.titleTextView = (TextView) convertView.findViewById(R.id.storage_label); + holder.summaryTextView = (TextView) convertView.findViewById(R.id.available_free_space); + holder.checkedTextView = (CheckedTextView) convertView.findViewById(R.id.checked_text_view); + convertView.setTag(holder); + } + + holder = (ViewHolder) convertView.getTag(); + holder.titleTextView.setText(getItem(position)); + holder.summaryTextView.setText(mFreeSpaces[position]); + holder.checkedTextView.setText(null); // we have a 'custom' label + if (position == mSelectedIndex) { + holder.checkedTextView.setChecked(true); + } + + return convertView; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranCheckBoxPreference.java b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranCheckBoxPreference.java new file mode 100644 index 0000000000..2d82d700a3 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranCheckBoxPreference.java @@ -0,0 +1,44 @@ +package com.quran.labs.androidquran.ui.preference; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.preference.CheckBoxPreference; +import android.support.annotation.NonNull; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class QuranCheckBoxPreference extends CheckBoxPreference { + + public QuranCheckBoxPreference(Context context, AttributeSet attrs, + int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public QuranCheckBoxPreference(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public QuranCheckBoxPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public QuranCheckBoxPreference(Context context) { + super(context); + } + + @Override + protected void onBindView(@NonNull View view) { + super.onBindView(view); + if (isEnabled()) { + final TextView tv = (TextView) view.findViewById(android.R.id.title); + if (tv != null) { + tv.setTextColor(Color.WHITE); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranHeaderPreference.java b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranHeaderPreference.java new file mode 100644 index 0000000000..f4c34faaff --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranHeaderPreference.java @@ -0,0 +1,54 @@ +package com.quran.labs.androidquran.ui.preference; + +import com.quran.labs.androidquran.R; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.preference.Preference; +import android.support.annotation.NonNull; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class QuranHeaderPreference extends Preference { + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public QuranHeaderPreference(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + public QuranHeaderPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public QuranHeaderPreference(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public QuranHeaderPreference(Context context) { + super(context); + init(); + } + + private void init() { + setLayoutResource(R.layout.about_header); + setSelectable(false); + } + + @Override + protected void onBindView(@NonNull View view) { + super.onBindView(view); + if (isEnabled()) { + final TextView tv = (TextView) view.findViewById(android.R.id.title); + if (tv != null) { + tv.setTextColor(Color.WHITE); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranListPreference.java b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranListPreference.java new file mode 100644 index 0000000000..f6262e1f64 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranListPreference.java @@ -0,0 +1,45 @@ +package com.quran.labs.androidquran.ui.preference; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.preference.ListPreference; +import android.support.annotation.NonNull; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class QuranListPreference extends ListPreference { + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public QuranListPreference(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public QuranListPreference(Context context, + AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public QuranListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public QuranListPreference(Context context) { + super(context); + } + + @Override + protected void onBindView(@NonNull View view) { + super.onBindView(view); + if (isEnabled()) { + final TextView title = (TextView) view.findViewById(android.R.id.title); + if (title != null) { + title.setTextColor(Color.WHITE); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranPreference.java b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranPreference.java new file mode 100644 index 0000000000..b235ecb3c8 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/preference/QuranPreference.java @@ -0,0 +1,43 @@ +package com.quran.labs.androidquran.ui.preference; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.preference.Preference; +import android.support.annotation.NonNull; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class QuranPreference extends Preference { + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public QuranPreference(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public QuranPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public QuranPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public QuranPreference(Context context) { + super(context); + } + + @Override + protected void onBindView(@NonNull View view) { + super.onBindView(view); + if (isEnabled()) { + final TextView tv = (TextView) view.findViewById(android.R.id.title); + if (tv != null) { + tv.setTextColor(Color.WHITE); + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/preference/SeekBarPreference.java b/app/src/main/java/com/quran/labs/androidquran/ui/preference/SeekBarPreference.java new file mode 100644 index 0000000000..5c8244792d --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/preference/SeekBarPreference.java @@ -0,0 +1,126 @@ +/* The following code was written by Matthew Wiggins + * and is released under the APACHE 2.0 license + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +package com.quran.labs.androidquran.ui.preference; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.os.Build; +import android.preference.Preference; +import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.util.QuranUtils; + +public class SeekBarPreference extends Preference implements SeekBar.OnSeekBarChangeListener { + + private static final String ANDROID_NS = "http://schemas.android.com/apk/res/android"; + + private Context mContext; + private SeekBar mSeekBar; + private TextView mValueText; + + private String mSuffix; + private int mTintColor; + private int mCurrentValue; + private int mDefault, mMax, mValue = 0; + + public SeekBarPreference(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + mSuffix = attrs.getAttributeValue(ANDROID_NS, "text"); + mDefault = attrs.getAttributeIntValue(ANDROID_NS, "defaultValue", + Constants.DEFAULT_TEXT_SIZE); + mMax = attrs.getAttributeIntValue(ANDROID_NS, "max", 100); + setLayoutResource(R.layout.seekbar_pref); + mTintColor = ContextCompat.getColor(context, R.color.accent_color); + } + + @Override + protected View onCreateView(ViewGroup parent) { + View view = super.onCreateView(parent); + mSeekBar = (SeekBar) view.findViewById(R.id.seekbar); + mValueText = (TextView) view.findViewById(R.id.value); + mSeekBar.setOnSeekBarChangeListener(this); + styleSeekBar(); + return view; + } + + private void styleSeekBar() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + final Drawable progressDrawable = mSeekBar.getProgressDrawable(); + if (progressDrawable != null) { + if (progressDrawable instanceof LayerDrawable) { + LayerDrawable ld = (LayerDrawable) progressDrawable; + int layers = ld.getNumberOfLayers(); + for (int i = 0; i < layers; i++) { + ld.getDrawable(i).mutate().setColorFilter(mTintColor, PorterDuff.Mode.SRC_ATOP); + } + } else { + progressDrawable.mutate().setColorFilter(mTintColor, PorterDuff.Mode.SRC_ATOP); + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + final Drawable thumb = mSeekBar.getThumb(); + if (thumb != null) { + thumb.mutate().setColorFilter(mTintColor, PorterDuff.Mode.SRC_ATOP); + } + } + } + } + + @Override + protected void onBindView(@NonNull View view) { + super.onBindView(view); + if (isEnabled()) { + final TextView tv = (TextView) view.findViewById(android.R.id.title); + if (tv != null) { + tv.setTextColor(Color.WHITE); + } + } + mValue = shouldPersist() ? getPersistedInt(mDefault) : 0; + mSeekBar.setMax(mMax); + mSeekBar.setProgress(mValue); + } + + @Override + protected void onSetInitialValue(boolean restore, Object defaultValue) { + super.onSetInitialValue(restore, defaultValue); + if (restore) { + mValue = shouldPersist() ? getPersistedInt(mDefault) : 0; + } else { + mValue = (Integer) defaultValue; + } + } + + @Override + public void onProgressChanged(SeekBar seek, int value, boolean fromTouch) { + String t = QuranUtils.getLocalizedNumber(mContext, value); + mValueText.setText(mSuffix == null ? t : t.concat(mSuffix)); + mCurrentValue = value; + } + + public void onStartTrackingTouch(SeekBar seek) { + } + + public void onStopTrackingTouch(SeekBar seek) { + if (shouldPersist()) { + persistInt(mCurrentValue); + callChangeListener(mCurrentValue); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationAdapter.java new file mode 100644 index 0000000000..aaa0ee4605 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationAdapter.java @@ -0,0 +1,295 @@ +package com.quran.labs.androidquran.ui.translation; + +import android.content.Context; +import android.graphics.Color; +import android.os.Build; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.text.SpannableString; +import android.text.Spanned; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils; +import com.quran.labs.androidquran.ui.helpers.UthmaniSpan; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.widgets.AyahNumberView; +import com.quran.labs.androidquran.widgets.DividerView; + +import java.util.ArrayList; +import java.util.List; + +import butterknife.BindView; +import butterknife.ButterKnife; + +class TranslationAdapter extends RecyclerView.Adapter { + private static final boolean USE_UTHMANI_SPAN = + Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR1; + private static final float ARABIC_MULTIPLIER = 1.4f; + + private static final int HIGHLIGHT_CHANGE = 1; + + private final Context context; + private final LayoutInflater inflater; + private final RecyclerView recyclerView; + private final List data; + private View.OnClickListener onClickListener; + + private int fontSize; + private int textColor; + private int dividerColor; + private int arabicTextColor; + private int suraHeaderColor; + private int ayahSelectionColor; + private boolean isNightMode; + + private int highlightedAyah; + private int highlightedRowCount; + private int highlightedStartPosition; + + private View.OnClickListener defaultClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (onClickListener != null) { + onClickListener.onClick(v); + } + } + }; + + TranslationAdapter(Context context, RecyclerView recyclerView) { + this.context = context; + this.data = new ArrayList<>(); + this.recyclerView = recyclerView; + this.inflater = LayoutInflater.from(context); + } + + void setData(List data) { + this.data.clear(); + this.data.addAll(data); + if (highlightedAyah > 0) { + highlightAyah(highlightedAyah, false); + } + } + + void setHighlightedAyah(int ayahId) { + highlightAyah(ayahId, true); + } + + private void highlightAyah(int ayahId, boolean notify) { + if (ayahId != highlightedAyah) { + int count = 0; + int startPosition = -1; + for (int i = 0, size = this.data.size(); i < size; i++) { + QuranAyahInfo item = this.data.get(i).ayahInfo; + if (item.ayahId == ayahId) { + if (count == 0) { + startPosition = i; + } + count++; + } else if (count > 0) { + break; + } + } + + // highlight the newly highlighted ayah + if (count > 0 && notify) { + int startChangeCount = count; + int startChangeRange = startPosition; + if (highlightedRowCount > 0) { + // merge the requests for notifyItemRangeChanged when we're either the next ayah + if (highlightedStartPosition + highlightedRowCount + 1 == startPosition) { + startChangeRange = highlightedStartPosition; + startChangeCount = startChangeCount + highlightedRowCount; + } else if (highlightedStartPosition - 1 == startPosition + count) { + // ... or when we're the previous ayah + startChangeCount = startChangeCount + highlightedRowCount; + } else { + // otherwise, unhighlight + notifyItemRangeChanged(highlightedStartPosition, highlightedRowCount, HIGHLIGHT_CHANGE); + } + } + + // and update rows to be highlighted + notifyItemRangeChanged(startChangeRange, startChangeCount, HIGHLIGHT_CHANGE); + recyclerView.smoothScrollToPosition(startPosition + count); + } + + highlightedAyah = ayahId; + highlightedStartPosition = startPosition; + highlightedRowCount = count; + } + } + + void unhighlight() { + if (highlightedAyah > 0 && highlightedRowCount > 0) { + notifyItemRangeChanged(highlightedStartPosition, highlightedRowCount); + } + + highlightedAyah = 0; + highlightedRowCount = 0; + highlightedStartPosition = -1; + } + + void setOnTranslationClickedListener(View.OnClickListener listener) { + this.onClickListener = listener; + } + + void refresh(QuranSettings quranSettings) { + this.fontSize = quranSettings.getTranslationTextSize(); + isNightMode = quranSettings.isNightMode(); + if (isNightMode) { + int textBrightness = quranSettings.getNightModeTextBrightness(); + this.textColor = Color.rgb(textBrightness, textBrightness, textBrightness); + this.arabicTextColor = textColor; + this.dividerColor = textColor; + this.suraHeaderColor = ContextCompat.getColor(context, R.color.translation_sura_header_night); + this.ayahSelectionColor = + ContextCompat.getColor(context, R.color.translation_ayah_selected_color_night); + } else { + this.textColor = ContextCompat.getColor(context, R.color.translation_text_color); + this.dividerColor = ContextCompat.getColor(context, R.color.translation_divider_color); + this.arabicTextColor = Color.BLACK; + this.suraHeaderColor = ContextCompat.getColor(context, R.color.translation_sura_header); + this.ayahSelectionColor = + ContextCompat.getColor(context, R.color.translation_ayah_selected_color); + } + + if (!this.data.isEmpty()) { + notifyDataSetChanged(); + } + } + + @Override + public int getItemViewType(int position) { + return data.get(position).type; + } + + @Override + public RowViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + @LayoutRes int layout; + if (viewType == TranslationViewRow.Type.SURA_HEADER) { + layout = R.layout.quran_translation_header_row; + } else if (viewType == TranslationViewRow.Type.BASMALLAH || + viewType == TranslationViewRow.Type.QURAN_TEXT) { + layout = R.layout.quran_translation_arabic_row; + } else if (viewType == TranslationViewRow.Type.SPACER) { + layout = R.layout.quran_translation_spacer_row; + } else if (viewType == TranslationViewRow.Type.VERSE_NUMBER) { + layout = R.layout.quran_translation_verse_number_row; + } else if (viewType == TranslationViewRow.Type.TRANSLATOR) { + layout = R.layout.quran_translation_translator_row; + } else { + layout = R.layout.quran_translation_text_row; + } + View view = inflater.inflate(layout, parent, false); + return new RowViewHolder(view); + } + + @Override + public void onBindViewHolder(RowViewHolder holder, int position) { + TranslationViewRow row = data.get(position); + + if (holder.text != null) { + final CharSequence text; + if (row.type == TranslationViewRow.Type.SURA_HEADER) { + text = QuranInfo.getSuraName(context, row.ayahInfo.sura, true); + holder.text.setBackgroundColor(suraHeaderColor); + } else if (row.type == TranslationViewRow.Type.BASMALLAH || + row.type == TranslationViewRow.Type.QURAN_TEXT) { + SpannableString str = new SpannableString(row.type == TranslationViewRow.Type.BASMALLAH ? + ArabicDatabaseUtils.AR_BASMALLAH : ArabicDatabaseUtils.getAyahWithoutBasmallah( + row.ayahInfo.sura, row.ayahInfo.ayah, row.ayahInfo.arabicText)); + if (USE_UTHMANI_SPAN) { + str.setSpan(new UthmaniSpan(context), 0, str.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + text = str; + holder.text.setTextColor(arabicTextColor); + holder.text.setTextSize(ARABIC_MULTIPLIER * fontSize); + } else { + if (row.type == TranslationViewRow.Type.TRANSLATOR) { + text = row.data; + } else { + // translation + text = row.data; + holder.text.setTextColor(textColor); + holder.text.setTextSize(fontSize); + } + } + holder.text.setText(text); + } else if (holder.divider != null) { + boolean showLine = true; + if (position + 1 < data.size()) { + TranslationViewRow nextRow = data.get(position + 1); + if (nextRow.ayahInfo.sura != row.ayahInfo.sura) { + showLine = false; + } + } else { + showLine = false; + } + holder.divider.toggleLine(showLine); + holder.divider.setDividerColor(dividerColor); + } else if (holder.ayahNumber != null) { + String text = context.getString(R.string.sura_ayah, row.ayahInfo.sura, row.ayahInfo.ayah); + holder.ayahNumber.setAyahString(text); + holder.ayahNumber.setTextColor(textColor); + holder.ayahNumber.setNightMode(isNightMode); + } + updateHighlight(row, holder); + } + + @Override + public void onBindViewHolder(RowViewHolder holder, int position, List payloads) { + if (payloads.contains(HIGHLIGHT_CHANGE)) { + updateHighlight(data.get(position), holder); + } else { + super.onBindViewHolder(holder, position, payloads); + } + } + + private void updateHighlight(TranslationViewRow row, RowViewHolder holder) { + // toggle highlighting of the ayah, but not for sura headers and basmallah + boolean isHighlighted = row.ayahInfo.ayahId == highlightedAyah; + if (row.type != TranslationViewRow.Type.SURA_HEADER && + row.type != TranslationViewRow.Type.BASMALLAH && + row.type != TranslationViewRow.Type.SPACER) { + if (isHighlighted) { + holder.wrapperView.setBackgroundColor(ayahSelectionColor); + } else { + holder.wrapperView.setBackgroundColor(0); + } + } else if (holder.divider != null) { // SPACER type + if (isHighlighted) { + holder.divider.highlight(ayahSelectionColor); + } else { + holder.divider.unhighlight(); + } + } + } + + @Override + public int getItemCount() { + return data.size(); + } + + class RowViewHolder extends RecyclerView.ViewHolder { + @NonNull View wrapperView; + @BindView(R.id.text) @Nullable TextView text; + @BindView(R.id.divider) @Nullable DividerView divider; + @BindView(R.id.ayah_number) @Nullable AyahNumberView ayahNumber; + + RowViewHolder(@NonNull View itemView) { + super(itemView); + this.wrapperView = itemView; + ButterKnife.bind(this, itemView); + itemView.setOnClickListener(defaultClickListener); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationView.java b/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationView.java new file mode 100644 index 0000000000..621c27d472 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationView.java @@ -0,0 +1,111 @@ +package com.quran.labs.androidquran.ui.translation; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ViewGroup; + +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.ArrayList; +import java.util.List; + +public class TranslationView extends ViewGroup { + private final RecyclerView translationRecycler; + private final TranslationAdapter translationAdapter; + + public TranslationView(Context context) { + this(context, null); + } + + public TranslationView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public TranslationView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + translationRecycler = new RecyclerView(context); + translationRecycler.setLayoutManager(new LinearLayoutManager(context)); + translationRecycler.setItemAnimator(new DefaultItemAnimator()); + translationAdapter = new TranslationAdapter(context, translationRecycler); + translationRecycler.setAdapter(translationAdapter); + addView(translationRecycler, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + translationRecycler.measure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + int width = translationRecycler.getMeasuredWidth(); + int x = ((r - l) - width) / 2; + translationRecycler.layout(x, 0, x + width, getMeasuredHeight()); + } + + public void setVerses(@NonNull String[] translations, @NonNull List verses) { + List rows = new ArrayList<>(); + + int currentSura = -1; + boolean wantTranslationHeaders = translations.length > 1; + for (int i = 0, size = verses.size(); i < size; i++) { + QuranAyahInfo verse = verses.get(i); + int sura = verse.sura; + if (sura != currentSura) { + rows.add(new TranslationViewRow(TranslationViewRow.Type.SURA_HEADER, verse)); + currentSura = sura; + } + + if (verse.ayah == 1 && sura != 1 && sura != 9) { + rows.add(new TranslationViewRow(TranslationViewRow.Type.BASMALLAH, verse)); + } + + rows.add(new TranslationViewRow(TranslationViewRow.Type.VERSE_NUMBER, verse)); + + if (verse.arabicText != null) { + rows.add(new TranslationViewRow(TranslationViewRow.Type.QURAN_TEXT, verse)); + } + + // added this to guard against a crash that happened when verse.texts was empty + int verseTexts = verse.texts.size(); + for (int j = 0; j < translations.length; j++) { + String text = verseTexts > j ? verse.texts.get(j) : ""; + if (!TextUtils.isEmpty(text)) { + if (wantTranslationHeaders) { + rows.add( + new TranslationViewRow(TranslationViewRow.Type.TRANSLATOR, verse, translations[j])); + } + rows.add(new TranslationViewRow(TranslationViewRow.Type.TRANSLATION_TEXT, verse, text)); + } + } + + rows.add(new TranslationViewRow(TranslationViewRow.Type.SPACER, verse)); + } + + translationAdapter.setData(rows); + translationAdapter.notifyDataSetChanged(); + } + + public void refresh(@NonNull QuranSettings quranSettings) { + translationAdapter.refresh(quranSettings); + } + + public void setTranslationClickedListener(OnClickListener listener) { + translationAdapter.setOnTranslationClickedListener(listener); + } + + public void highlightAyah(int ayahId) { + translationAdapter.setHighlightedAyah(ayahId); + } + + public void unhighlightAyat() { + translationAdapter.unhighlight(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationViewRow.java b/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationViewRow.java new file mode 100644 index 0000000000..13ebc1a977 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/translation/TranslationViewRow.java @@ -0,0 +1,36 @@ +package com.quran.labs.androidquran.ui.translation; + +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.common.QuranAyahInfo; + +class TranslationViewRow { + + @IntDef({ Type.BASMALLAH, Type.SURA_HEADER, Type.QURAN_TEXT, Type.TRANSLATOR, + Type.TRANSLATION_TEXT, Type.VERSE_NUMBER, Type.SPACER }) + @interface Type { + int BASMALLAH = 0; + int SURA_HEADER = 1; + int QURAN_TEXT = 2; + int TRANSLATOR = 3; + int TRANSLATION_TEXT = 4; + int VERSE_NUMBER = 5; + int SPACER = 6; + } + + @Type final int type; + @NonNull final QuranAyahInfo ayahInfo; + @Nullable final String data; + + TranslationViewRow(int type, @NonNull QuranAyahInfo ayahInfo) { + this(type, ayahInfo, null); + } + + TranslationViewRow(int type, @NonNull QuranAyahInfo ayahInfo, @Nullable String data) { + this.type = type; + this.ayahInfo = ayahInfo; + this.data = data; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/util/ImageAyahUtils.java b/app/src/main/java/com/quran/labs/androidquran/ui/util/ImageAyahUtils.java new file mode 100644 index 0000000000..87f980fbe2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/util/ImageAyahUtils.java @@ -0,0 +1,202 @@ +package com.quran.labs.androidquran.ui.util; + +import android.graphics.Matrix; +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.util.SparseArray; +import android.widget.ImageView; + +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.widgets.AyahToolBar; +import com.quran.labs.androidquran.widgets.HighlightingImageView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import timber.log.Timber; + +public class ImageAyahUtils { + + private static SuraAyah getAyahFromKey(String key){ + String[] parts = key.split(":"); + SuraAyah result = null; + if (parts.length == 2){ + try { + int sura = Integer.parseInt(parts[0]); + int ayah = Integer.parseInt(parts[1]); + result = new SuraAyah(sura, ayah); + } + catch (Exception e){ + // no op + } + } + return result; + } + + public static SuraAyah getAyahFromCoordinates( + Map> coords, + HighlightingImageView imageView, float xc, float yc) { + if (coords == null || imageView == null){ return null; } + + float[] pageXY = getPageXY(xc, yc, imageView); + if (pageXY == null){ return null; } + float x = pageXY[0]; + float y = pageXY[1]; + + int closestLine = -1; + int closestDelta = -1; + + final SparseArray> lineAyahs = new SparseArray<>(); + final Set keys = coords.keySet(); + for (String key : keys){ + List bounds = coords.get(key); + if (bounds == null){ continue; } + + for (AyahBounds b : bounds){ + // only one AyahBound will exist for an ayah on a particular line + int line = b.getLine(); + List items = lineAyahs.get(line); + if (items == null){ + items = new ArrayList<>(); + } + items.add(key); + lineAyahs.put(line, items); + + final RectF boundsRect = b.getBounds(); + if (boundsRect.contains(x, y)) { + return getAyahFromKey(key); + } + + int delta = Math.min((int) Math.abs(boundsRect.bottom - y), + (int) Math.abs(boundsRect.top - y)); + if (closestDelta == -1 || delta < closestDelta){ + closestLine = b.getLine(); + closestDelta = delta; + } + } + } + + if (closestLine > -1){ + int leastDeltaX = -1; + String closestAyah = null; + List ayat = lineAyahs.get(closestLine); + if (ayat != null){ + Timber.d("no exact match, %d candidates.", ayat.size()); + for (String ayah : ayat){ + List bounds = coords.get(ayah); + if (bounds == null){ continue; } + for (AyahBounds b : bounds){ + if (b.getLine() > closestLine){ + // this is the last ayah in ayat list + break; + } + + final RectF boundsRect = b.getBounds(); + if (b.getLine() == closestLine){ + // if x is within the x of this ayah, that's our answer + if (boundsRect.right >= x && boundsRect.left <= x){ + return getAyahFromKey(ayah); + } + + // otherwise, keep track of the least delta and return it + int delta = Math.min((int) Math.abs(boundsRect.right - x), + (int) Math.abs(boundsRect.left - x)); + if (leastDeltaX == -1 || delta < leastDeltaX){ + closestAyah = ayah; + leastDeltaX = delta; + } + } + } + } + } + + if (closestAyah != null){ + Timber.d("fell back to closest ayah of %s", closestAyah); + return getAyahFromKey(closestAyah); + } + } + return null; + } + + public static AyahToolBar.AyahToolBarPosition getToolBarPosition( + @NonNull List bounds, @NonNull Matrix matrix, + int screenWidth, int screenHeight, int toolBarWidth, int toolBarHeight) { + boolean isToolBarUnderAyah = false; + AyahToolBar.AyahToolBarPosition result = null; + final int size = bounds.size(); + + RectF chosenRect; + if (size > 0) { + RectF firstRect = new RectF(); + AyahBounds chosen = bounds.get(0); + matrix.mapRect(firstRect, chosen.getBounds()); + chosenRect = new RectF(firstRect); + + float y = firstRect.top - toolBarHeight; + if (y < toolBarHeight) { + // too close to the top, let's move to the bottom + chosen = bounds.get(size - 1); + matrix.mapRect(chosenRect, chosen.getBounds()); + y = chosenRect.bottom; + if (y > (screenHeight - toolBarHeight)) { + y = firstRect.bottom; + chosenRect = firstRect; + } + isToolBarUnderAyah = true; + } + + final float midpoint = chosenRect.centerX(); + float x = midpoint - (toolBarWidth / 2); + if (x < 0 || x + toolBarWidth > screenWidth) { + x = chosenRect.left; + if (x + toolBarWidth > screenWidth) { + x = screenWidth - toolBarWidth; + } + } + + result = new AyahToolBar.AyahToolBarPosition(); + result.x = x; + result.y = y; + result.pipOffset = midpoint - x; + result.pipPosition = isToolBarUnderAyah ? + AyahToolBar.PipPosition.UP : AyahToolBar.PipPosition.DOWN; + } + return result; + } + + private static float[] getPageXY( + float screenX, float screenY, ImageView imageView) { + if (imageView.getDrawable() == null) { + return null; + } + + float[] results = null; + Matrix inverse = new Matrix(); + if (imageView.getImageMatrix().invert(inverse)) { + results = new float[2]; + inverse.mapPoints(results, new float[]{screenX, screenY}); + } + return results; + } + + public static RectF getYBoundsForHighlight( + Map> coordinateData, int sura, int ayah) { + final List ayahBounds = coordinateData.get(sura + ":" + ayah); + if (ayahBounds == null) { + return null; + } + + RectF ayahBoundsRect = null; + for (AyahBounds bounds : ayahBounds) { + if (ayahBoundsRect == null) { + ayahBoundsRect = bounds.getBounds(); + } else { + ayahBoundsRect.union(bounds.getBounds()); + } + } + return ayahBoundsRect; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/util/PageController.java b/app/src/main/java/com/quran/labs/androidquran/ui/util/PageController.java new file mode 100644 index 0000000000..ea3b51142e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/util/PageController.java @@ -0,0 +1,11 @@ +package com.quran.labs.androidquran.ui.util; + +import android.view.MotionEvent; + +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; + +public interface PageController { + boolean handleTouchEvent(MotionEvent event, AyahSelectedListener.EventType eventType, int page); + void handleRetryClicked(); + void onScrollChanged(int x, int y, int oldx, int oldy); +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/util/TranslationsSpinnerAdapter.java b/app/src/main/java/com/quran/labs/androidquran/ui/util/TranslationsSpinnerAdapter.java new file mode 100644 index 0000000000..d3950bd02e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/util/TranslationsSpinnerAdapter.java @@ -0,0 +1,183 @@ +package com.quran.labs.androidquran.ui.util; + +import android.content.Context; +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.LocalTranslation; +import com.quran.labs.androidquran.ui.PagerActivity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class TranslationsSpinnerAdapter extends ArrayAdapter { + + private Context context; + private final LayoutInflater layoutInflater; + + private String[] translationNames; + private List translations; + private Set selectedItems; + private OnSelectionChangedListener listener; + + public TranslationsSpinnerAdapter(Context context, + int resource, + String[] translationNames, + List translations, + Set selectedItems, + OnSelectionChangedListener listener) { + // intentionally making a new ArrayList instead of using the constructor for String[]. + // this is because clear() relies on being able to clear the List passed into the constructor, + // and the String[] constructor makes a new (immutable) List with the items of the array. + super(context, resource, new ArrayList<>()); + this.context = context; + this.layoutInflater = LayoutInflater.from(this.context); + translationNames = updateTranslationNames(translationNames); + this.translationNames = translationNames; + this.translations = translations; + this.selectedItems = selectedItems; + this.listener = listener; + addAll(translationNames); + } + + private View.OnClickListener onCheckedChangeListener = buttonView -> { + CheckBoxHolder holder = (CheckBoxHolder) ((View) buttonView.getParent()).getTag(); + LocalTranslation localTranslation = translations.get(holder.position); + + boolean updated = true; + if (selectedItems.contains(localTranslation.filename)) { + if (selectedItems.size() > 1) { + selectedItems.remove(localTranslation.filename); + } else { + updated = false; + holder.checkBox.setChecked(true); + } + } else { + selectedItems.add(localTranslation.filename); + } + + if (updated && listener != null) { + listener.onSelectionChanged(selectedItems); + } + + }; + + private View.OnClickListener onTextClickedListener = textView -> { + CheckBoxHolder holder = (CheckBoxHolder) ((View) textView.getParent()).getTag(); + if (holder.position == translationNames.length - 1) { + if (this.context instanceof PagerActivity) { + final PagerActivity pagerActivity = (PagerActivity) this.context; + pagerActivity.startTranslationManager(); + } + } else { + holder.checkBox.performClick(); + } + }; + + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + SpinnerHolder holder; + if (convertView == null) { + holder = new SpinnerHolder(); + convertView = layoutInflater.inflate(R.layout.translation_ab_spinner_selected, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.title); + holder.subtitle = (TextView) convertView.findViewById(R.id.subtitle); + convertView.setTag(holder); + } + holder = (SpinnerHolder) convertView.getTag(); + + holder.title.setText(R.string.translations); + holder.subtitle.setVisibility(View.GONE); + + return convertView; + } + + @Override + public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) { + CheckBoxHolder holder; + if (convertView == null) { + convertView = layoutInflater.inflate( + R.layout.translation_ab_spinner_item, parent, false); + convertView.setTag(new CheckBoxHolder(convertView)); + } + holder = (CheckBoxHolder) convertView.getTag(); + holder.position = position; + holder.textView.setOnClickListener(onTextClickedListener); + if (position == translationNames.length - 1) { + holder.checkBox.setVisibility(View.GONE); + holder.checkBox.setOnClickListener(null); + Resources r = convertView.getResources(); + float leftPadding = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, r.getDisplayMetrics()); + holder.textView.setPadding(Math.round(leftPadding), 0, 0, 0); + holder.textView.setText(R.string.more_translations); + } else { + holder.checkBox.setVisibility(View.VISIBLE); + holder.checkBox.setChecked(selectedItems.contains(translations.get(position).filename)); + holder.checkBox.setOnClickListener(onCheckedChangeListener); + holder.textView.setText(translationNames[position]); + } + + return convertView; + } + + public int getItemViewType(int position) { + if (position == translationNames.length - 1) { + return 1; // Last item in spinner should be text "More Translations" + } else { + return 0; + } + } + + public void updateItems(String[] translationNames, + List translations, + Set selectedItems) { + clear(); + translationNames = updateTranslationNames(translationNames); + this.translationNames = translationNames; + this.translations = translations; + this.selectedItems = selectedItems; + addAll(translationNames); + notifyDataSetChanged(); + } + + private static class CheckBoxHolder { + final CheckBox checkBox; + final TextView textView; + int position; + + CheckBoxHolder(View view) { + this.checkBox = (CheckBox) view.findViewById(R.id.checkbox); + this.textView = (TextView) view.findViewById(R.id.text); + } + } + + protected static class SpinnerHolder { + public TextView title; + public TextView subtitle; + } + + public interface OnSelectionChangedListener { + void onSelectionChanged(Set selectedItems); + } + + private String[] updateTranslationNames(String[] translationNames) { + List translationsList = new ArrayList<>(); + for (String translation : translationNames) { + translationsList.add(translation); + } + translationsList.add(getContext().getString(R.string.more_translations)); + translationNames = translationsList.toArray(new String[translationsList.size()]); + + return translationNames; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/util/TypefaceManager.java b/app/src/main/java/com/quran/labs/androidquran/ui/util/TypefaceManager.java new file mode 100644 index 0000000000..8199fb4072 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/ui/util/TypefaceManager.java @@ -0,0 +1,32 @@ +package com.quran.labs.androidquran.ui.util; + +import com.quran.labs.androidquran.data.QuranFileConstants; + +import android.content.Context; +import android.graphics.Typeface; +import android.support.annotation.NonNull; + +public class TypefaceManager { + public static final int TYPE_UTHMANI_HAFS = 1; + public static final int TYPE_NOOR_HAYAH = 2; + + private static Typeface sTypeface; + + public static Typeface getUthmaniTypeface(@NonNull Context context) { + if (sTypeface == null) { + final String fontName; + switch (QuranFileConstants.FONT_TYPE) { + case TYPE_NOOR_HAYAH: { + fontName = "noorehira.ttf"; + break; + } + case TYPE_UTHMANI_HAFS: + default: { + fontName = "uthmanic_hafs_ver09.otf"; + } + } + sTypeface = Typeface.createFromAsset(context.getAssets(), fontName); + } + return sTypeface; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/AudioManagerUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/AudioManagerUtils.java new file mode 100644 index 0000000000..0735457336 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/AudioManagerUtils.java @@ -0,0 +1,95 @@ +package com.quran.labs.androidquran.util; + + +import android.support.annotation.NonNull; +import android.util.Pair; + +import com.quran.labs.androidquran.common.QariItem; +import com.quran.labs.androidquran.data.QuranInfo; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.reactivex.Observable; +import io.reactivex.ObservableSource; +import io.reactivex.Single; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + + +public class AudioManagerUtils { + + private static String padSuraNumber(int number) { + return number < 10 ? "00" + number : + number < 100 ? "0" + number : + String.valueOf(number); + } + + private static Map sCache = new ConcurrentHashMap<>(); + + public static void clearCache() { + sCache.clear(); + } + + public static void clearCacheKeyForSheikh(QariItem qariItem) { + sCache.remove(qariItem); + } + + @NonNull + public static Single> shuyookhDownloadObservable( + final String basePath, List qariItems) { + return Observable.fromIterable(qariItems) + .flatMap(new Function>() { + @Override + public ObservableSource apply(QariItem item) throws Exception { + QariDownloadInfo cached = sCache.get(item); + if (cached != null) { + return Observable.just(cached); + } + + File baseFile = new File(basePath, item.getPath()); + return !baseFile.exists() ? Observable.just(new QariDownloadInfo(item)) : + item.isGapless() ? getGaplessSheikhObservable(baseFile, item).toObservable() : + getGappedSheikhObservable(baseFile, item).toObservable(); + } + }) + .doOnNext(qariDownloadInfo -> sCache.put(qariDownloadInfo.qariItem, qariDownloadInfo)) + .toList() + .subscribeOn(Schedulers.io()); + } + + @NonNull + private static Single getGaplessSheikhObservable( + final File path, final QariItem qariItem) { + return Observable.range(1, 114) + .map(sura -> new SuraFileName(sura, new File(path, padSuraNumber(sura) + ".mp3"))) + .filter(sf -> sf.file.exists()) + .map(sf -> sf.sura) + .toList() + .map(suras -> new QariDownloadInfo(qariItem, suras)); + } + + @NonNull + private static Single getGappedSheikhObservable( + final File basePath, final QariItem qariItem) { + return Observable.range(1, 114) + .map(sura -> new SuraFileName(sura, new File(basePath, String.valueOf(sura)))) + .filter(suraFile -> suraFile.file.exists()) + .map(sf -> new Pair<>(sf.sura, + sf.file.listFiles().length >= QuranInfo.SURA_NUM_AYAHS[sf.sura - 1])) + .toList() + .map(downloaded -> QariDownloadInfo.withPartials(qariItem, downloaded)); + } + + private static class SuraFileName { + public final int sura; + public final File file; + + SuraFileName(int sura, File file) { + this.sura = sura; + this.file = file; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.java new file mode 100644 index 0000000000..e848bcc2bc --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.java @@ -0,0 +1,329 @@ +package com.quran.labs.androidquran.util; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QariItem; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; +import com.quran.labs.androidquran.service.AudioService; +import com.quran.labs.androidquran.service.util.AudioRequest; +import com.quran.labs.androidquran.service.util.DownloadAudioRequest; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import timber.log.Timber; + +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST; + +public class AudioUtils { + + public static final String AUDIO_EXTENSION = ".mp3"; + + private static final String DB_EXTENSION = ".db"; + private static final String ZIP_EXTENSION = ".zip"; + + final static class LookAheadAmount { + static final int PAGE = 1; + static final int SURA = 2; + static final int JUZ = 3; + + // make sure to update these when a lookup type is added + static final int MIN = 1; + static final int MAX = 3; + } + + /** + * Get a list of QariItem representing the qaris to show + * + * This method takes into account qaris that exist both in gapped and gapless, and, in those + * cases, hides the gapped version if it contains no files. + * + * @param context the current context + * @return a list of QariItem representing the qaris to show. + */ + public static List getQariList(@NonNull Context context) { + final Resources resources = context.getResources(); + final String[] shuyookh = resources.getStringArray(R.array.quran_readers_name); + final String[] paths = resources.getStringArray(R.array.quran_readers_path); + final String[] urls = resources.getStringArray(R.array.quran_readers_urls); + final String[] databases = resources.getStringArray(R.array.quran_readers_db_name); + final int[] hasGaplessEquivalent = + resources.getIntArray(R.array.quran_readers_have_gapless_equivalents); + List items = new ArrayList<>(shuyookh.length); + for (int i=0; i < shuyookh.length; i++) { + if (hasGaplessEquivalent[i] == 0 || haveAnyFiles(context, paths[i])) { + items.add(new QariItem(i, shuyookh[i], urls[i], paths[i], databases[i])); + } + } + Collections.sort(items, new Comparator() { + @Override + public int compare(QariItem lhs, QariItem rhs) { + boolean lhsGapless = lhs.isGapless(); + boolean rhsGapless = rhs.isGapless(); + if (lhsGapless != rhsGapless) { + return lhsGapless ? -1 : 1; + } + return lhs.getName().compareTo(rhs.getName()); + } + }); + return items; + } + + public static String getQariUrl(@NonNull QariItem item) { + String url = item.getUrl(); + if (item.isGapless()) { + url += "%03d" + AudioUtils.AUDIO_EXTENSION; + } else { + url += "%03d%03d" + AudioUtils.AUDIO_EXTENSION; + } + return url; + } + + public static String getLocalQariUrl(@NonNull Context context, @NonNull QariItem item) { + String rootDirectory = QuranFileUtils.getQuranAudioDirectory(context); + return rootDirectory == null ? null : rootDirectory + item.getPath(); + } + + public static String getQariDatabasePathIfGapless( + @NonNull Context context, @NonNull QariItem item) { + String databaseName = item.getDatabaseName(); + if (databaseName != null) { + String path = getLocalQariUrl(context, item); + if (path != null) { + databaseName = path + File.separator + databaseName + DB_EXTENSION; + } + } + return databaseName; + } + + public static boolean shouldDownloadGaplessDatabase(DownloadAudioRequest request) { + if (!request.isGapless()) { + return false; + } + String dbPath = request.getGaplessDatabaseFilePath(); + if (TextUtils.isEmpty(dbPath)) { + return false; + } + + File f = new File(dbPath); + return !f.exists(); + } + + public static String getGaplessDatabaseUrl(DownloadAudioRequest request) { + if (!request.isGapless()) { + return null; + } + + QariItem item = request.getQariItem(); + String dbname = item.getDatabaseName() + ZIP_EXTENSION; + return QuranFileUtils.getGaplessDatabaseRootUrl() + "/" + dbname; + } + + public static SuraAyah getLastAyahToPlay(SuraAyah startAyah, + int page, int mode, boolean isDualPages) { + if (isDualPages && mode == LookAheadAmount.PAGE && (page % 2 == 1)) { + // if we download page by page and we are currently in tablet mode + // and playing from the right page, get the left page as well. + page++; + } + + int pageLastSura = 114; + int pageLastAyah = 6; + // page < 0 - intentional, because nextPageAyah looks up the ayah on the next page + if (page > PAGES_LAST || page < 0) { + return null; + } + if (page < PAGES_LAST) { + int nextPageSura = QuranInfo.safelyGetSuraOnPage(page); + // not using [page-1] as an index because we literally want the next page + int nextPageAyah = QuranInfo.PAGE_AYAH_START[page]; + + pageLastSura = nextPageSura; + pageLastAyah = nextPageAyah - 1; + if (pageLastAyah < 1) { + pageLastSura--; + if (pageLastSura < 1) { + pageLastSura = 1; + } + pageLastAyah = QuranInfo.getNumAyahs(pageLastSura); + } + } + + if (mode == LookAheadAmount.SURA) { + int sura = startAyah.sura; + int lastAyah = QuranInfo.getNumAyahs(sura); + if (lastAyah == -1) { + return null; + } + + // if we start playback between two suras, download both suras + if (pageLastSura > sura) { + sura = pageLastSura; + lastAyah = QuranInfo.getNumAyahs(sura); + } + return new SuraAyah(sura, lastAyah); + } else if (mode == LookAheadAmount.JUZ) { + int juz = QuranInfo.getJuzFromPage(page); + if (juz == 30) { + return new SuraAyah(114, 6); + } else if (juz >= 1 && juz < 30) { + int[] endJuz = QuranInfo.QUARTERS[juz * 8]; + if (pageLastSura > endJuz[0]) { + // ex between jathiya and a7qaf + endJuz = QuranInfo.QUARTERS[(juz + 1) * 8]; + } else if (pageLastSura == endJuz[0] && + pageLastAyah > endJuz[1]) { + // ex surat al anfal + endJuz = QuranInfo.QUARTERS[(juz + 1) * 8]; + } + + return new SuraAyah(endJuz[0], endJuz[1]); + } + } + + // page mode (fallback also from errors above) + return new SuraAyah(pageLastSura, pageLastAyah); + } + + public static boolean shouldDownloadBasmallah(DownloadAudioRequest request) { + if (request.isGapless()) { + return false; + } + String baseDirectory = request.getLocalPath(); + if (!TextUtils.isEmpty(baseDirectory)) { + File f = new File(baseDirectory); + if (f.exists()) { + String filename = 1 + File.separator + 1 + AUDIO_EXTENSION; + f = new File(baseDirectory + File.separator + filename); + if (f.exists()) { + Timber.d("already have basmalla..."); + return false; + } + } else { + f.mkdirs(); + } + } + + return doesRequireBasmallah(request); + } + + public static boolean haveSuraAyahForQari(String baseDir, int sura, int ayah) { + String filename = baseDir + File.separator + sura + + File.separator + ayah + AUDIO_EXTENSION; + File f = new File(filename); + return f.exists(); + } + + private static boolean doesRequireBasmallah(AudioRequest request) { + SuraAyah minAyah = request.getMinAyah(); + int startSura = minAyah.sura; + int startAyah = minAyah.ayah; + + SuraAyah maxAyah = request.getMaxAyah(); + int endSura = maxAyah.sura; + int endAyah = maxAyah.ayah; + + Timber.d("seeing if need basmalla..."); + + for (int i = startSura; i <= endSura; i++) { + int lastAyah = QuranInfo.getNumAyahs(i); + if (i == endSura) { + lastAyah = endAyah; + } + int firstAyah = 1; + if (i == startSura) { + firstAyah = startAyah; + } + + for (int j = firstAyah; j < lastAyah; j++) { + if (j == 1 && i != 1 && i != 9) { + Timber.d("need basmalla for %d:%d", i, j); + + return true; + } + } + } + + return false; + } + + private static boolean haveAnyFiles(Context context, String path) { + final String basePath = QuranFileUtils.getQuranAudioDirectory(context); + final File file = new File(basePath, path); + return file.isDirectory() && file.list().length > 0; + } + + public static boolean haveAllFiles(DownloadAudioRequest request) { + String baseDirectory = request.getLocalPath(); + if (TextUtils.isEmpty(baseDirectory)) { + return false; + } + + boolean isGapless = request.isGapless(); + File f = new File(baseDirectory); + if (!f.exists()) { + f.mkdirs(); + return false; + } + + SuraAyah minAyah = request.getMinAyah(); + int startSura = minAyah.sura; + int startAyah = minAyah.ayah; + + SuraAyah maxAyah = request.getMaxAyah(); + int endSura = maxAyah.sura; + int endAyah = maxAyah.ayah; + + for (int i = startSura; i <= endSura; i++) { + int lastAyah = QuranInfo.getNumAyahs(i); + if (i == endSura) { + lastAyah = endAyah; + } + int firstAyah = 1; + if (i == startSura) { + firstAyah = startAyah; + } + + if (isGapless) { + if (i == endSura && endAyah == 0) { + continue; + } + String p = request.getBaseUrl(); + String fileName = String.format(Locale.US, p, i); + Timber.d("gapless, checking if we have %s", fileName); + f = new File(fileName); + if (!f.exists()) { + return false; + } + continue; + } + + Timber.d("not gapless, checking each ayah..."); + for (int j = firstAyah; j <= lastAyah; j++) { + String filename = i + File.separator + j + AUDIO_EXTENSION; + f = new File(baseDirectory + File.separator + filename); + if (!f.exists()) { + return false; + } + } + } + + return true; + } + + public static Intent getAudioIntent(Context context, String action) { + final Intent intent = new Intent(context, AudioService.class); + intent.setAction(action); + return intent; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/QariDownloadInfo.java b/app/src/main/java/com/quran/labs/androidquran/util/QariDownloadInfo.java new file mode 100644 index 0000000000..e98d315020 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/QariDownloadInfo.java @@ -0,0 +1,43 @@ +package com.quran.labs.androidquran.util; + +import android.util.Pair; +import android.util.SparseBooleanArray; + +import com.quran.labs.androidquran.common.QariItem; + +import java.util.Collections; +import java.util.List; + +public class QariDownloadInfo { + + public final QariItem qariItem; + public SparseBooleanArray downloadedSuras; + private SparseBooleanArray partialSuras; + + QariDownloadInfo(QariItem item) { + this(item, Collections.emptyList()); + } + + QariDownloadInfo(QariItem item, List suras) { + this.qariItem = item; + this.partialSuras = new SparseBooleanArray(); + this.downloadedSuras = new SparseBooleanArray(); + for (int i = 0, surasSize = suras.size(); i < surasSize; i++) { + Integer sura = suras.get(i); + this.downloadedSuras.put(sura, true); + } + } + + static QariDownloadInfo withPartials(QariItem item, List> suras) { + QariDownloadInfo info = new QariDownloadInfo(item, Collections.emptyList()); + for (int i = 0, surasSize = suras.size(); i < surasSize; i++) { + Pair sura = suras.get(i); + if (sura.second) { + info.downloadedSuras.put(sura.first, true); + } else { + info.partialSuras.put(sura.first, true); + } + } + return info; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/QuranAppUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/QuranAppUtils.java new file mode 100644 index 0000000000..225716dc2e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/QuranAppUtils.java @@ -0,0 +1,139 @@ +package com.quran.labs.androidquran.util; + +import android.text.TextUtils; + +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.data.QuranInfo; +import com.quran.labs.androidquran.data.SuraAyah; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.Callable; + +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +public class QuranAppUtils { + + public static Single getQuranAppUrlObservable(final String key, + final SuraAyah start, final SuraAyah end) { + return Single.fromCallable(new Callable() { + @Override + public String call() throws Exception { + int sura = start.sura; + int startAyah = start.ayah; + // quranapp only supports sharing within a sura + int endAyah = end.sura == start.sura ? end.ayah : QuranInfo.getNumAyahs(start.sura); + return QuranAppUtils.getQuranAppUrl(key, sura, startAyah, endAyah); + } + }).subscribeOn(Schedulers.io()); + } + + private static String getQuranAppUrl(String key, int sura, Integer startAyah, Integer endAyah) { + String url = null; + String fallbackUrl = null; + try { + Map params = new HashMap(); + params.put("surah", sura + ""); + fallbackUrl = Constants.QURAN_APP_BASE + sura; + if (startAyah != null) { + params.put("start_ayah", startAyah.toString()); + fallbackUrl += "/" + startAyah; + if (endAyah != null) { + params.put("end_ayah", endAyah.toString()); + fallbackUrl += "-" + endAyah; + } else { + params.put("end_ayah", startAyah.toString()); + } + } + params.put("key", key); + String result = getQuranAppUrl(params); + if (!TextUtils.isEmpty(result)) { + JSONObject json = new JSONObject(result); + url = json.getString("url"); + } + } catch (Exception e) { + Timber.d(e, "error getting QuranApp url"); + } + + Timber.d("got back %s and fallback %s", url, fallbackUrl); + return TextUtils.isEmpty(url) ? fallbackUrl : url; + } + + private static String getQuranAppUrl(Map params) + throws IOException { + URL url = null; + try { + url = new URL(Constants.QURAN_APP_ENDPOINT); + } catch (MalformedURLException me) { + // ignore + } + + StringBuilder builder = new StringBuilder(); + Iterator> iterator = + params.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry item = iterator.next(); + builder.append(item.getKey()).append("=").append(item.getValue()); + if (iterator.hasNext()) { + builder.append('&'); + } + } + + String result = ""; + String body = builder.toString(); + byte[] bytes = body.getBytes(); + HttpURLConnection conn = null; + try { + // TODO: use OkHttp + conn = (HttpURLConnection) url.openConnection(); + conn.setReadTimeout(10000); + conn.setConnectTimeout(15000); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setUseCaches(false); + conn.setFixedLengthStreamingMode(bytes.length); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", + "application/x-www-form-urlencoded;charset=UTF-8"); + + // post the request + OutputStream out = conn.getOutputStream(); + out.write(bytes); + out.close(); + + // handle the response + BufferedReader reader = + new BufferedReader(new InputStreamReader( + conn.getInputStream(), "UTF-8")); + + String line; + while ((line = reader.readLine()) != null) { + result += line; + } + + try { + reader.close(); + } catch (Exception e) { + // ignore + } + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + return result; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/QuranFileUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/QuranFileUtils.java new file mode 100644 index 0000000000..57ec832746 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/QuranFileUtils.java @@ -0,0 +1,583 @@ +package com.quran.labs.androidquran.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Environment; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.quran.labs.androidquran.BuildConfig; +import com.quran.labs.androidquran.common.Response; +import com.quran.labs.androidquran.data.QuranDataProvider; +import com.quran.labs.androidquran.data.QuranFileConstants; + +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Locale; + +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import timber.log.Timber; + +import static com.quran.labs.androidquran.data.Constants.PAGES_LAST; + +public class QuranFileUtils { + + // server urls + private static final String IMG_BASE_URL = QuranFileConstants.IMG_BASE_URL; + private static final String IMG_ZIP_BASE_URL = QuranFileConstants.IMG_ZIP_BASE_URL; + private static final String PATCH_ZIP_BASE_URL = QuranFileConstants.PATCH_ZIP_BASE_URL; + private static final String DATABASE_BASE_URL = QuranFileConstants.DATABASE_BASE_URL; + private static final String AYAHINFO_BASE_URL = QuranFileConstants.AYAHINFO_BASE_URL; + private static final String AUDIO_DB_BASE_URL = QuranFileConstants.AUDIO_DB_BASE_URL; + + // local paths + private static final String QURAN_BASE = QuranFileConstants.QURAN_BASE; + private static final String DATABASE_DIRECTORY = QuranFileConstants.DATABASE_DIRECTORY; + private static final String AUDIO_DIRECTORY = QuranFileConstants.AUDIO_DIRECTORY; + private static final String AYAHINFO_DIRECTORY = QuranFileConstants.AYAHINFO_DIRECTORY; + private static final String IMAGES_DIRECTORY = QuranFileConstants.IMAGES_DIRECTORY; + + // check if the images with the given width param have a version + // that we specify (ex if version is 3, check for a .v3 file). + public static boolean isVersion(Context context, String widthParam, int version) { + String quranDirectory = getQuranImagesDirectory(context, widthParam); + Timber.d("isVersion: checking if version %d exists for width %s at %s", + version, widthParam, quranDirectory); + if (quranDirectory == null) { + return false; + } + + // version 1 or below are true as long as you have images + if (version <= 1) { + return true; + } + + // check the version code + try { + File vFile = new File(quranDirectory + + File.separator + ".v" + version); + return vFile.exists(); + } catch (Exception e) { + Timber.e(e, "isVersion: exception while checking version file"); + return false; + } + } + + public static String getPotentialFallbackDirectory(Context context) { + final String state = Environment.getExternalStorageState(); + if (state.equals(Environment.MEDIA_MOUNTED)) { + if (haveAllImages(context, "_1920")) { + return "1920"; + } else if (haveAllImages(context, "_1280")) { + return "1280"; + } else if (haveAllImages(context, "_1024")) { + return "1024"; + } else { + return ""; + } + } + return null; + } + + public static boolean haveAllImages(Context context, String widthParam) { + String quranDirectory = getQuranImagesDirectory(context, widthParam); + Timber.d("haveAllImages: for width %s, directory is: %s", widthParam, quranDirectory); + if (quranDirectory == null) { + return false; + } + + String state = Environment.getExternalStorageState(); + if (state.equals(Environment.MEDIA_MOUNTED)) { + File dir = new File(quranDirectory + File.separator); + if (dir.isDirectory()) { + Timber.d("haveAllImages: media state is mounted and directory exists"); + String[] fileList = dir.list(); + if (fileList == null) { + Timber.d("haveAllImages: null fileList, checking page by page..."); + for (int i = 1; i <= PAGES_LAST; i++) { + if (!new File(dir, getPageFileName(i)).exists()) { + Timber.d("haveAllImages: couldn't find page %d", i); + return false; + } + } + } else if (fileList.length < PAGES_LAST) { + // ideally, we should loop for each page and ensure + // all pages are there, but this will do for now. + Timber.d("haveAllImages: found %d files instead of 604.", fileList.length); + return false; + } + return true; + } else { + Timber.d("haveAllImages: couldn't find the directory, so making it instead"); + QuranFileUtils.makeQuranDirectory(context); + if (!IMAGES_DIRECTORY.isEmpty()) { + QuranFileUtils.makeQuranImagesDirectory(context); + } + } + } + return false; + } + + public static String getPageFileName(int p) { + NumberFormat nf = NumberFormat.getInstance(Locale.US); + nf.setMinimumIntegerDigits(3); + return "page" + nf.format(p) + ".png"; + } + + private static boolean isSDCardMounted() { + String state = Environment.getExternalStorageState(); + return state.equals(Environment.MEDIA_MOUNTED); + } + + @NonNull + public static Response getImageFromSD(Context context, String widthParam, String filename) { + String location; + if (widthParam != null) { + location = getQuranImagesDirectory(context, widthParam); + } else { + location = getQuranImagesDirectory(context); + } + + if (location == null) { + return new Response(Response.ERROR_SD_CARD_NOT_FOUND); + } + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ALPHA_8; + final Bitmap bitmap = BitmapFactory.decodeFile(location + + File.separator + filename, options); + return bitmap == null ? new Response(Response.ERROR_FILE_NOT_FOUND) : new Response(bitmap); + } + + private static boolean writeNoMediaFile(String parentDir) { + File f = new File(parentDir + "/.nomedia"); + if (f.exists()) { + return true; + } + + try { + return f.createNewFile(); + } catch (IOException e) { + return false; + } + } + + public static boolean makeQuranDirectory(Context context) { + String path = getQuranImagesDirectory(context); + if (path == null) { + return false; + } + + File directory = new File(path); + if (directory.exists() && directory.isDirectory()) { + return writeNoMediaFile(path); + } else { + return directory.mkdirs() && writeNoMediaFile(path); + } + } + + private static boolean makeQuranImagesDirectory(Context context) { + return makeDirectory(getQuranImagesDirectory(context)); + } + + private static boolean makeDirectory(String path) { + if (path == null) { + return false; + } + + File directory = new File(path); + return directory.exists() && directory.isDirectory() || directory.mkdirs(); + } + + private static boolean makeQuranDatabaseDirectory(Context context) { + return makeDirectory(getQuranDatabaseDirectory(context)); + } + + private static boolean makeQuranAyahDatabaseDirectory(Context context) { + return makeQuranDatabaseDirectory(context) && + makeDirectory(getQuranAyahDatabaseDirectory(context)); + } + + public static Response getImageFromWeb(OkHttpClient okHttpClient, + Context context, String filename) { + return getImageFromWeb(okHttpClient, context, filename, false); + } + + @NonNull + private static Response getImageFromWeb(OkHttpClient okHttpClient, + Context context, String filename, boolean isRetry) { + QuranScreenInfo instance = QuranScreenInfo.getInstance(); + if (instance == null) { + instance = QuranScreenInfo.getOrMakeInstance(context); + } + + String urlString = IMG_BASE_URL + "width" + + instance.getWidthParam() + File.separator + + filename; + Timber.d("want to download: %s", urlString); + + final Request request = new Request.Builder() + .url(urlString) + .build(); + final Call call = okHttpClient.newCall(request); + + InputStream stream = null; + try { + final okhttp3.Response response = call.execute(); + if (response.isSuccessful()) { + stream = response.body().byteStream(); + final Bitmap bitmap = decodeBitmapStream(stream); + if (bitmap != null) { + String path = getQuranImagesDirectory(context); + int warning = Response.WARN_SD_CARD_NOT_FOUND; + if (path != null && QuranFileUtils.makeQuranDirectory(context)) { + path += File.separator + filename; + warning = tryToSaveBitmap(bitmap, path) ? 0 : Response.WARN_COULD_NOT_SAVE_FILE; + } + return new Response(bitmap, warning); + } + } + } catch (IOException ioe) { + Timber.e(ioe, "exception downloading file"); + } finally { + closeQuietly(stream); + } + + return isRetry ? new Response(Response.ERROR_DOWNLOADING_ERROR) : + getImageFromWeb(okHttpClient, context, filename, true); + } + + private static Bitmap decodeBitmapStream(InputStream is) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ALPHA_8; + return BitmapFactory.decodeStream(is, null, options); + } + + public static void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (Exception e) { + // no op + } + } + } + + private static boolean tryToSaveBitmap(Bitmap bitmap, String savePath) { + FileOutputStream output = null; + try { + output = new FileOutputStream(savePath); + return bitmap.compress(Bitmap.CompressFormat.PNG, 100, output); + } catch (IOException ioe) { + // do nothing + } finally { + try { + if (output != null) { + output.flush(); + output.close(); + } + } catch (Exception e) { + // ignore... + } + } + return false; + } + + @Nullable + public static String getQuranBaseDirectory(Context context) { + String basePath = QuranSettings.getInstance(context).getAppCustomLocation(); + + if (!isSDCardMounted()) { + // if our best guess suggests that we won't have access to the data due to the sdcard not + // being mounted, then set the base path to null for now. + if (basePath == null || basePath.equals( + Environment.getExternalStorageDirectory().getAbsolutePath()) || + (basePath.contains(BuildConfig.APPLICATION_ID) && context.getExternalFilesDir(null) == null)) { + basePath = null; + } + } + + if (basePath != null) { + if (!basePath.endsWith(File.separator)) { + basePath += File.separator; + } + return basePath + QURAN_BASE; + } + return null; + } + + /** + * Returns the app used space in megabytes + */ + public static int getAppUsedSpace(Context context) { + final String baseDirectory = getQuranBaseDirectory(context); + if (baseDirectory == null) { + return -1; + } + + File base = new File(baseDirectory); + ArrayList files = new ArrayList<>(); + files.add(base); + long size = 0; + while (!files.isEmpty()) { + File f = files.remove(0); + if (f.isDirectory()) { + File[] subFiles = f.listFiles(); + if (subFiles != null) { + Collections.addAll(files, subFiles); + } + } else { + size += f.length(); + } + } + return (int) (size / (long) (1024 * 1024)); + } + + public static String getQuranDatabaseDirectory(Context context) { + String base = getQuranBaseDirectory(context); + return (base == null) ? null : base + DATABASE_DIRECTORY; + } + + public static String getQuranAyahDatabaseDirectory(Context context) { + String base = getQuranBaseDirectory(context); + return base == null ? null : base + File.separator + AYAHINFO_DIRECTORY; + } + + @Nullable + public static String getQuranAudioDirectory(Context context){ + String path = getQuranBaseDirectory(context); + if (path == null) { + return null; + } + path += AUDIO_DIRECTORY; + File dir = new File(path); + if (!dir.exists() && !dir.mkdirs()) { + return null; + } + + writeNoMediaFile(path); + return path + File.separator; + } + + public static String getQuranImagesBaseDirectory(Context context) { + String s = QuranFileUtils.getQuranBaseDirectory(context); + return s == null ? null : s + IMAGES_DIRECTORY; + } + + private static String getQuranImagesDirectory(Context context) { + QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + if (qsi == null) { + return null; + } + return getQuranImagesDirectory(context, qsi.getWidthParam()); + } + + private static String getQuranImagesDirectory(Context context, String widthParam) { + String base = getQuranBaseDirectory(context); + return (base == null) ? null : base + + (IMAGES_DIRECTORY.isEmpty() ? "" : IMAGES_DIRECTORY + File.separator) + "width" + widthParam; + } + + public static String getZipFileUrl() { + QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + if (qsi == null) { + return null; + } + return getZipFileUrl(qsi.getWidthParam()); + } + + public static String getZipFileUrl(String widthParam) { + String url = IMG_ZIP_BASE_URL; + url += "images" + widthParam + ".zip"; + return url; + } + + public static String getPatchFileUrl(String widthParam, int toVersion) { + return PATCH_ZIP_BASE_URL + toVersion + "/patch" + + widthParam + "_v" + toVersion + ".zip"; + } + + private static String getAyaPositionFileName() { + QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + if (qsi == null) { + return null; + } + return getAyaPositionFileName(qsi.getWidthParam()); + } + + public static String getAyaPositionFileName(String widthParam) { + return "ayahinfo" + widthParam + ".db"; + } + + public static String getAyaPositionFileUrl() { + QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + if (qsi == null) { + return null; + } + return getAyaPositionFileUrl(qsi.getWidthParam()); + } + + public static String getAyaPositionFileUrl(String widthParam) { + return AYAHINFO_BASE_URL + "ayahinfo" + widthParam + ".zip"; + } + + static String getGaplessDatabaseRootUrl() { + QuranScreenInfo qsi = QuranScreenInfo.getInstance(); + if (qsi == null) { + return null; + } + return AUDIO_DB_BASE_URL; + } + + public static boolean haveAyaPositionFile(Context context) { + String base = QuranFileUtils.getQuranAyahDatabaseDirectory(context); + if (base == null) { + QuranFileUtils.makeQuranAyahDatabaseDirectory(context); + } + String filename = QuranFileUtils.getAyaPositionFileName(); + if (filename != null) { + String ayaPositionDb = base + File.separator + filename; + File f = new File(ayaPositionDb); + return f.exists(); + } + + return false; + } + + public static boolean hasTranslation(Context context, String fileName) { + String path = getQuranDatabaseDirectory(context); + if (path != null) { + path += File.separator + fileName; + return new File(path).exists(); + } + return false; + } + + public static boolean removeTranslation(Context context, String fileName) { + String path = getQuranDatabaseDirectory(context); + if (path != null) { + path += File.separator + fileName; + File f = new File(path); + return f.delete(); + } + return false; + } + + public static boolean hasArabicSearchDatabase(Context context) { + if (hasTranslation(context, QuranDataProvider.QURAN_ARABIC_DATABASE)) { + return true; + } else if (!DATABASE_DIRECTORY.equals(AYAHINFO_DIRECTORY)){ + // non-hafs flavors copy their ayahinfo and arabic search database in a subdirectory, + // so we copy back the arabic database into the translations directory where it can + // be shared across all flavors of quran android + final File ayahInfoFile = new File(getQuranAyahDatabaseDirectory(context), + QuranDataProvider.QURAN_ARABIC_DATABASE); + final String baseDir = getQuranDatabaseDirectory(context); + if (ayahInfoFile.exists() && baseDir != null) { + final File base = new File(baseDir); + final File translationsFile = new File(base, QuranDataProvider.QURAN_ARABIC_DATABASE); + if (base.mkdir()) { + try { + copyFile(ayahInfoFile, translationsFile); + return true; + } catch (IOException ioe) { + if (!translationsFile.delete()) { + Timber.e("Error deleting translations file"); + } + } + } + } + } + return false; + } + + public static String getArabicSearchDatabaseUrl() { + return DATABASE_BASE_URL + QuranDataProvider.QURAN_ARABIC_DATABASE; + } + + public static boolean moveAppFiles(Context context, String newLocation) { + if (QuranSettings.getInstance(context).getAppCustomLocation().equals(newLocation)) { + return true; + } + final String baseDir = getQuranBaseDirectory(context); + if (baseDir == null) { + return false; + } + File currentDirectory = new File(baseDir); + File newDirectory = new File(newLocation, QURAN_BASE); + if (!currentDirectory.exists()) { + // No files to copy, so change the app directory directly + return true; + } else if (newDirectory.exists() || newDirectory.mkdirs()) { + try { + copyFileOrDirectory(currentDirectory, newDirectory); + deleteFileOrDirectory(currentDirectory); + return true; + } catch (IOException e) { + Timber.e(e, "error moving app files"); + } + } + return false; + } + + private static void deleteFileOrDirectory(File file) { + if (file.isDirectory()) { + File[] subFiles = file.listFiles(); + // subFiles is null on some devices, despite this being a directory + int length = subFiles == null ? 0 : subFiles.length; + for (int i = 0; i < length; i++) { + File sf = subFiles[i]; + if (sf.isFile()) { + if (!sf.delete()) { + Timber.e("Error deleting %s", sf.getPath()); + } + } else { + deleteFileOrDirectory(sf); + } + } + } + if (!file.delete()) { + Timber.e("Error deleting %s", file.getPath()); + } + } + + private static void copyFileOrDirectory(File source, File destination) throws IOException { + if (source.isDirectory()) { + if (!destination.exists() && !destination.mkdirs()) { + return; + } + + File[] files = source.listFiles(); + for (File f : files) { + copyFileOrDirectory(f, new File(destination, f.getName())); + } + } else { + copyFile(source, destination); + } + } + + private static void copyFile(File source, File destination) throws IOException { + InputStream in = new FileInputStream(source); + OutputStream out = new FileOutputStream(destination); + + byte[] buffer = new byte[1024]; + int length; + while ((length = in.read(buffer)) > 0) { + out.write(buffer, 0, length); + } + out.flush(); + out.close(); + in.close(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/QuranScreenInfo.java b/app/src/main/java/com/quran/labs/androidquran/util/QuranScreenInfo.java new file mode 100644 index 0000000000..591b233aca --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/QuranScreenInfo.java @@ -0,0 +1,137 @@ +package com.quran.labs.androidquran.util; + +import android.content.Context; +import android.graphics.Point; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.view.Display; +import android.view.WindowManager; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.QuranConstants; + +import timber.log.Timber; + +public class QuranScreenInfo { + private static QuranScreenInfo sInstance = null; + private static int sOrientation; + + private int mHeight; + private int mMaxWidth; + private PageProvider mPageProvider; + + private QuranScreenInfo(@NonNull Display display) { + final Point point = new Point(); + display.getSize(point); + + mHeight = point.y; + mMaxWidth = (point.x > point.y) ? point.x : point.y; + mPageProvider = QuranConstants.getPageProvider(display); + Timber.d("initializing with %d and %d", point.y, point.x); + } + + public static QuranScreenInfo getInstance() { + return sInstance; + } + + public static QuranScreenInfo getOrMakeInstance(Context context) { + if (sInstance == null || + sOrientation != context.getResources().getConfiguration().orientation) { + sInstance = initialize(context); + sOrientation = context.getResources().getConfiguration().orientation; + } + return sInstance; + } + + private static QuranScreenInfo initialize(Context context) { + final WindowManager w = (WindowManager) context + .getSystemService(Context.WINDOW_SERVICE); + final Display display = w.getDefaultDisplay(); + QuranScreenInfo qsi = new QuranScreenInfo(display); + qsi.setOverrideParam(QuranSettings.getInstance(context).getDefaultImagesDirectory()); + return qsi; + } + + public void setOverrideParam(String overrideParam) { + mPageProvider.setOverrideParameter(overrideParam); + } + + public int getHeight() { + return mHeight; + } + + public String getWidthParam() { + return "_" + mPageProvider.getWidthParameter(); + } + + public String getTabletWidthParam() { + return "_" + mPageProvider.getTabletWidthParameter(); + } + + public boolean isTablet(Context context) { + return context != null && mMaxWidth > 800 && context.getResources() + .getBoolean(R.bool.is_tablet); + } + + public static class DefaultPageProvider implements PageProvider { + + private final int mMaxWidth; + private String mOverrideParam; + + public DefaultPageProvider(@NonNull Display display) { + final Point point = new Point(); + display.getSize(point); + + mMaxWidth = (point.x > point.y) ? point.x : point.y; + } + + @Override + public String getWidthParameter() { + if (mMaxWidth <= 320) { + return "320"; + } else if (mMaxWidth <= 480) { + return "480"; + } else if (mMaxWidth <= 800) { + return "800"; + } else if (mMaxWidth <= 1280) { + return "1024"; + } else { + if (!TextUtils.isEmpty(mOverrideParam)) { + return mOverrideParam; + } + return "1260"; + } + } + + @Override + public String getTabletWidthParameter() { + if ("1260".equals(getWidthParameter())) { + // for tablet, if the width is more than 1280, use 1260 + // images for both dimens (only applies to new installs) + return "1260"; + } else { + int width = mMaxWidth / 2; + return getBestTabletLandscapeSizeMatch(width); + } + } + + @Override + public void setOverrideParameter(String parameter) { + mOverrideParam = parameter; + } + + private String getBestTabletLandscapeSizeMatch(int width) { + if (width <= 640) { + return "512"; + } else { + return "1024"; + } + } + } + + public interface PageProvider { + String getWidthParameter(); + String getTabletWidthParameter(); + void setOverrideParameter(String parameter); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/QuranSettings.java b/app/src/main/java/com/quran/labs/androidquran/util/QuranSettings.java new file mode 100644 index 0000000000..1b90cd257d --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/QuranSettings.java @@ -0,0 +1,341 @@ +package com.quran.labs.androidquran.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Environment; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; + +import com.quran.labs.androidquran.BuildConfig; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.service.QuranDownloadService; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + + +public class QuranSettings { + private static final String PREFS_FILE = "com.quran.labs.androidquran.per_installation"; + + private static QuranSettings instance; + private SharedPreferences prefs; + private SharedPreferences perInstallationPrefs; + + public static synchronized QuranSettings getInstance(@NonNull Context context) { + if (instance == null) { + instance = new QuranSettings(context.getApplicationContext()); + } + return instance; + } + + @VisibleForTesting + public static void setInstance(QuranSettings settings) { + instance = settings; + } + + private QuranSettings(@NonNull Context appContext) { + prefs = PreferenceManager.getDefaultSharedPreferences(appContext); + perInstallationPrefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE); + } + + public boolean isArabicNames() { + return prefs.getBoolean(Constants.PREF_USE_ARABIC_NAMES, false); + } + + public boolean isLockOrientation() { + return prefs.getBoolean(Constants.PREF_LOCK_ORIENTATION, false); + } + + public boolean isLandscapeOrientation() { + return prefs.getBoolean(Constants.PREF_LANDSCAPE_ORIENTATION, false); + } + + public boolean shouldStream() { + return prefs.getBoolean(Constants.PREF_PREFER_STREAMING, false); + } + + public boolean isNightMode() { + return prefs.getBoolean(Constants.PREF_NIGHT_MODE, false); + } + + public boolean useNewBackground() { + return prefs.getBoolean(Constants.PREF_USE_NEW_BACKGROUND, true); + } + + public boolean highlightBookmarks() { + return prefs.getBoolean(Constants.PREF_HIGHLIGHT_BOOKMARKS, true); + } + + public int getNightModeTextBrightness() { + return prefs.getInt(Constants.PREF_NIGHT_MODE_TEXT_BRIGHTNESS, + Constants.DEFAULT_NIGHT_MODE_TEXT_BRIGHTNESS); + } + + public boolean shouldOverlayPageInfo() { + return prefs.getBoolean(Constants.PREF_OVERLAY_PAGE_INFO, true); + } + + public boolean shouldDisplayMarkerPopup() { + return prefs.getBoolean(Constants.PREF_DISPLAY_MARKER_POPUP, true); + } + + public boolean shouldHighlightBookmarks() { + return prefs.getBoolean(Constants.PREF_HIGHLIGHT_BOOKMARKS, true); + } + + public boolean wantArabicInTranslationView() { + return prefs.getBoolean(Constants.PREF_AYAH_BEFORE_TRANSLATION, true); + } + + public int getPreferredDownloadAmount() { + String str = prefs.getString(Constants.PREF_DOWNLOAD_AMOUNT, + "" + AudioUtils.LookAheadAmount.PAGE); + int val = AudioUtils.LookAheadAmount.PAGE; + try { + val = Integer.parseInt(str); + } catch (Exception e) { + // no op + } + + if (val > AudioUtils.LookAheadAmount.MAX || + val < AudioUtils.LookAheadAmount.MIN) { + return AudioUtils.LookAheadAmount.PAGE; + } + return val; + } + + public int getTranslationTextSize() { + return prefs.getInt(Constants.PREF_TRANSLATION_TEXT_SIZE, + Constants.DEFAULT_TEXT_SIZE); + } + + public int getLastPage() { + return prefs.getInt(Constants.PREF_LAST_PAGE, Constants.NO_PAGE); + } + + public int getBookmarksSortOrder() { + return prefs.getInt(Constants.PREF_SORT_BOOKMARKS, 0); + } + + public void setBookmarksSortOrder(int sortOrder) { + prefs.edit().putInt(Constants.PREF_SORT_BOOKMARKS, sortOrder).apply(); + } + + public boolean getBookmarksGroupedByTags() { + return prefs.getBoolean(Constants.PREF_GROUP_BOOKMARKS_BY_TAG, false); + } + + public void setBookmarksGroupedByTags(boolean groupedByTags) { + prefs.edit().putBoolean(Constants.PREF_GROUP_BOOKMARKS_BY_TAG, groupedByTags).apply(); + } + + public boolean getShowRecents() { + return prefs.getBoolean(Constants.PREF_SHOW_RECENTS, true); + } + + public void setShowRecents(boolean minimizeRecents) { + prefs.edit().putBoolean(Constants.PREF_SHOW_RECENTS, minimizeRecents).apply(); + } + + // probably should eventually move this to Application.onCreate.. + public void upgradePreferences() { + int version = getVersion(); + if (version != BuildConfig.VERSION_CODE) { + if (version == 0) { + version = prefs.getInt(Constants.PREF_VERSION, 0); + } + + if (version != 0) { + if (version < 2672) { + // migrate preferences + setAppCustomLocation(prefs.getString(Constants.PREF_APP_LOCATION, null)); + + if (prefs.contains(Constants.PREF_SHOULD_FETCH_PAGES)) { + setShouldFetchPages(prefs.getBoolean(Constants.PREF_SHOULD_FETCH_PAGES, false)); + } + + if (prefs.contains(QuranDownloadService.PREF_LAST_DOWNLOAD_ERROR)) { + setLastDownloadError( + prefs.getString(QuranDownloadService.PREF_LAST_DOWNLOAD_ITEM, null), + prefs.getInt(QuranDownloadService.PREF_LAST_DOWNLOAD_ERROR, 0)); + } + + if (prefs.contains(Constants.PREF_ACTIVE_TRANSLATION)) { + setActiveTranslation(prefs.getString(Constants.PREF_ACTIVE_TRANSLATION, null)); + } + + prefs.edit() + .remove(Constants.PREF_VERSION) + .remove(Constants.PREF_APP_LOCATION) + .remove(Constants.PREF_SHOULD_FETCH_PAGES) + .remove(QuranDownloadService.PREF_LAST_DOWNLOAD_ERROR) + .remove(QuranDownloadService.PREF_LAST_DOWNLOAD_ITEM) + .remove(Constants.PREF_ACTIVE_TRANSLATION) + // these aren't migrated since they can be derived pretty easily + .remove("didPresentPermissionsRationale") // was renamed, removing old one + .remove(Constants.PREF_DEFAULT_IMAGES_DIR) + .remove(Constants.PREF_HAVE_UPDATED_TRANSLATIONS) + .remove(Constants.PREF_LAST_UPDATED_TRANSLATIONS) + .apply(); + } else if (version < 2674) { + // explicitly an else - if we migrated via the above, we're okay. otherwise, we are in + // a bad state due to not crashing in 2.6.7-p2 (thus getting its incorrect behavior), + // and thus crashing on 2.6.7-p3 and above (where the bug was fixed). this works around + // this issue. + try { + getLastDownloadItemWithError(); + getLastDownloadErrorCode(); + } catch (Exception e) { + clearLastDownloadError(); + } + } + } + + // no matter which version we're upgrading from, make sure the app location is set + if (!isAppLocationSet()) { + setAppCustomLocation(getAppCustomLocation()); + } + + // make sure that the version code now says that we're up to date. + setVersion(BuildConfig.VERSION_CODE); + } + } + + public boolean didPresentSdcardPermissionsDialog() { + return perInstallationPrefs.getBoolean(Constants.PREF_DID_PRESENT_PERMISSIONS_DIALOG, false); + } + + public void setSdcardPermissionsDialogPresented() { + perInstallationPrefs.edit() + .putBoolean(Constants.PREF_DID_PRESENT_PERMISSIONS_DIALOG, true).apply(); + } + + public String getAppCustomLocation() { + return perInstallationPrefs.getString(Constants.PREF_APP_LOCATION, + Environment.getExternalStorageDirectory().getAbsolutePath()); + } + + public void setAppCustomLocation(String newLocation) { + perInstallationPrefs.edit().putString(Constants.PREF_APP_LOCATION, newLocation).apply(); + } + + private boolean isAppLocationSet() { + return perInstallationPrefs.getString(Constants.PREF_APP_LOCATION, null) != null; + } + + @Deprecated + public String getActiveTranslation() { + return perInstallationPrefs.getString(Constants.PREF_ACTIVE_TRANSLATION, ""); + } + + @Deprecated + public void setActiveTranslation(String translation) { + perInstallationPrefs.edit().putString(Constants.PREF_ACTIVE_TRANSLATION, translation).apply(); + } + + public void removeActiveTranslation() { + perInstallationPrefs.edit().remove(Constants.PREF_ACTIVE_TRANSLATION).apply(); + } + + public void setActiveTranslations(Set activeTranslations) { + perInstallationPrefs.edit() + .putStringSet(Constants.PREF_ACTIVE_TRANSLATIONS, activeTranslations).apply(); + } + + public Set getActiveTranslations() { + if (!perInstallationPrefs.contains(Constants.PREF_ACTIVE_TRANSLATIONS)) { + String translation = perInstallationPrefs.getString(Constants.PREF_ACTIVE_TRANSLATION, null); + Set active = new HashSet<>(); + if (translation != null) { + active.add(translation); + } + return active; + } else { + return perInstallationPrefs.getStringSet( + Constants.PREF_ACTIVE_TRANSLATIONS, Collections.emptySet()); + } + } + + public int getVersion() { + return perInstallationPrefs.getInt(Constants.PREF_VERSION, 0); + } + + public void setVersion(int version) { + perInstallationPrefs.edit().putInt(Constants.PREF_VERSION, version).apply(); + } + + public boolean shouldFetchPages() { + return perInstallationPrefs.getBoolean(Constants.PREF_SHOULD_FETCH_PAGES, false); + } + + public void setShouldFetchPages(boolean shouldFetchPages) { + perInstallationPrefs.edit().putBoolean(Constants.PREF_SHOULD_FETCH_PAGES, shouldFetchPages).apply(); + } + + public void removeShouldFetchPages() { + perInstallationPrefs.edit().remove(Constants.PREF_SHOULD_FETCH_PAGES).apply(); + } + + public boolean haveUpdatedTranslations() { + return perInstallationPrefs.getBoolean(Constants.PREF_HAVE_UPDATED_TRANSLATIONS, false); + } + + public void setHaveUpdatedTranslations(boolean haveUpdatedTranslations) { + perInstallationPrefs.edit().putBoolean(Constants.PREF_HAVE_UPDATED_TRANSLATIONS, + haveUpdatedTranslations).apply(); + } + + public long getLastUpdatedTranslationDate() { + return perInstallationPrefs.getLong(Constants.PREF_LAST_UPDATED_TRANSLATIONS, + System.currentTimeMillis()); + } + + public void setLastUpdatedTranslationDate(long date) { + perInstallationPrefs.edit().putLong(Constants.PREF_LAST_UPDATED_TRANSLATIONS, date).apply(); + } + + public String getLastDownloadItemWithError() { + return perInstallationPrefs.getString(QuranDownloadService.PREF_LAST_DOWNLOAD_ITEM, ""); + } + + public int getLastDownloadErrorCode() { + return perInstallationPrefs.getInt(QuranDownloadService.PREF_LAST_DOWNLOAD_ERROR, 0); + } + + public void setLastDownloadError(String lastDownloadItem, int lastDownloadError) { + perInstallationPrefs.edit() + .putInt(QuranDownloadService.PREF_LAST_DOWNLOAD_ERROR, lastDownloadError) + .putString(QuranDownloadService.PREF_LAST_DOWNLOAD_ITEM, lastDownloadItem) + .apply(); + } + + public void clearLastDownloadError() { + perInstallationPrefs.edit() + .remove(QuranDownloadService.PREF_LAST_DOWNLOAD_ERROR) + .remove(QuranDownloadService.PREF_LAST_DOWNLOAD_ITEM) + .apply(); + } + + public boolean haveDefaultImagesDirectory() { + return perInstallationPrefs.contains(Constants.PREF_DEFAULT_IMAGES_DIR); + } + + public void setDefaultImagesDirectory(String directory) { + perInstallationPrefs.edit().putString(Constants.PREF_DEFAULT_IMAGES_DIR, directory).apply(); + } + + String getDefaultImagesDirectory() { + return perInstallationPrefs.getString(Constants.PREF_DEFAULT_IMAGES_DIR, ""); + } + + public void setWasShowingTranslation(boolean wasShowingTranslation) { + perInstallationPrefs.edit().putBoolean(Constants.PREF_WAS_SHOWING_TRANSLATION, + wasShowingTranslation).apply(); + } + + public boolean getWasShowingTranslation() { + return perInstallationPrefs.getBoolean(Constants.PREF_WAS_SHOWING_TRANSLATION, false); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/QuranUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/QuranUtils.java new file mode 100644 index 0000000000..85ac1e4a9f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/QuranUtils.java @@ -0,0 +1,170 @@ +package com.quran.labs.androidquran.util; + +import android.app.ActivityManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.v4.text.TextUtilsCompat; +import android.support.v4.view.ViewCompat; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; + +import java.io.File; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Locale; + +public class QuranUtils { + + private static boolean isArabicFormatter; + private static NumberFormat numberFormat; + private static Locale lastLocale; + + public static boolean doesStringContainArabic(String s) { + if (s == null) { + return false; + } + + int length = s.length(); + for (int i = 0; i < length; i++) { + int current = (int) s.charAt(i); + // Skip space + if (current == 32) { + continue; + } + // non-reshaped arabic + if ((current >= 1570) && (current <= 1610)) { + return true; + } + // re-shaped arabic + else if ((current >= 65133) && (current <= 65276)) { + return true; + } + // if the value is 42, it deserves another chance :p + // (in reality, 42 is a * which is useful in searching sqlite) + else if (current != 42) { + return false; + } + } + return false; + } + + public static boolean isRtl() { + return TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) + == ViewCompat.LAYOUT_DIRECTION_RTL; + } + + public static boolean isOnWifiNetwork(Context context) { + ConnectivityManager cm = + (ConnectivityManager) context.getSystemService( + Context.CONNECTIVITY_SERVICE); + + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + return activeNetwork != null && + activeNetwork.getType() == ConnectivityManager.TYPE_WIFI; + } + + public static boolean haveInternet(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService( + Context.CONNECTIVITY_SERVICE); + final NetworkInfo networkInfo = cm == null ? null : cm.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnectedOrConnecting(); + } + + public static String getLocalizedNumber(Context context, int number) { + Locale locale = Locale.getDefault(); + boolean isArabicNames = QuranSettings.getInstance(context).isArabicNames(); + boolean change = numberFormat == null || + !locale.equals(lastLocale) || + isArabicNames != isArabicFormatter; + + if (change) { + numberFormat = isArabicNames ? + DecimalFormat.getIntegerInstance(new Locale("ar")) : + DecimalFormat.getIntegerInstance(locale); + lastLocale = locale; + isArabicFormatter = isArabicNames; + } + return numberFormat.format(number); + } + + public static boolean isDualPages(Context context, QuranScreenInfo qsi) { + if (context != null && qsi != null) { + final Resources resources = context.getResources(); + if (qsi.isTablet(context) && + resources.getConfiguration().orientation == + Configuration.ORIENTATION_LANDSCAPE) { + final SharedPreferences prefs = + PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getBoolean(Constants.PREF_TABLET_ENABLED, + resources.getBoolean(R.bool.use_tablet_interface_by_default)); + } + } + return false; + } + + /** + * Is this a tablet that has the "dual pages" option set? + * @param context the context + * @param qsi the QuranScreenInfo instance + * @return whether or not this is a tablet with the "dual pages" option set, irrespective of + * the current orientation of the device. + */ + public static boolean isDualPagesInLandscape( + @NonNull Context context, @NonNull QuranScreenInfo qsi) { + if (qsi.isTablet(context)) { + final SharedPreferences prefs = + PreferenceManager.getDefaultSharedPreferences(context); + final Resources resources = context.getResources(); + return prefs.getBoolean(Constants.PREF_TABLET_ENABLED, + resources.getBoolean(R.bool.use_tablet_interface_by_default)); + } + return false; + } + + @WorkerThread + public static String getDebugInfo(Context context){ + StringBuilder builder = new StringBuilder(); + builder.append("Android SDK Version: ").append(Build.VERSION.SDK_INT); + String location = QuranSettings.getInstance(context).getAppCustomLocation(); + builder.append("\nApp Location:").append(location); + try { + File file = new File(location); + builder.append("\n App Location Directory ") + .append(file.exists() ? "exists" : "doesn't exist") + .append("\n Image zip files:"); + String[] list = file.list(); + for (String fileName : list) { + if (fileName.contains("images_")) { + File f = new File(fileName); + builder.append("\n file: ").append(fileName).append("\tlength: ").append(f.length()); + } + } + } catch (Exception e) { + builder.append("Exception trying to list files") + .append(e); + } + + QuranScreenInfo info = QuranScreenInfo.getInstance(); + if (info != null){ + builder.append("\nDisplay: ").append(info.getWidthParam()); + if (info.isTablet(context)){ + builder.append(", tablet width: ").append(info.getWidthParam()); + } + builder.append("\n"); + } + + int memClass = ((ActivityManager)context + .getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass(); + builder.append("memory class: ").append(memClass).append("\n\n"); + return builder.toString(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/RecordingLogTree.java b/app/src/main/java/com/quran/labs/androidquran/util/RecordingLogTree.java new file mode 100644 index 0000000000..34ad73d329 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/RecordingLogTree.java @@ -0,0 +1,58 @@ +package com.quran.labs.androidquran.util; + +import android.util.Log; + +import java.util.ArrayDeque; +import java.util.Deque; + +import timber.log.Timber; + +/** + * A logging implementation which buffers the last 200 messages. + * Slightly modified version of Telecine's BugsnagTree. + * https://github.com/JakeWharton/Telecine + */ +public class RecordingLogTree extends Timber.Tree { + + private static final int BUFFER_SIZE = 200; + + // Adding one to the initial size accounts for the add before remove. + private final Deque buffer = new ArrayDeque<>(BUFFER_SIZE + 1); + + @Override + protected void log(int priority, String tag, String message, Throwable t) { + message = System.currentTimeMillis() + " " + priorityToString(priority) + " " + message; + synchronized (buffer) { + buffer.addLast(message); + if (buffer.size() > BUFFER_SIZE) { + buffer.removeFirst(); + } + } + } + + + public String getLogs() { + StringBuilder builder = new StringBuilder(); + synchronized (buffer) { + for (String message : buffer) { + builder.append(message).append(".\n"); + } + } + return builder.toString(); + } + + private static String priorityToString(int priority) { + switch (priority) { + case Log.ERROR: + return "E"; + case Log.WARN: + return "W"; + case Log.INFO: + return "I"; + case Log.DEBUG: + return "D"; + default: + return String.valueOf(priority); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.java b/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.java new file mode 100644 index 0000000000..954d8c691a --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.java @@ -0,0 +1,75 @@ +package com.quran.labs.androidquran.util; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.support.annotation.StringRes; +import android.widget.Toast; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QuranText; +import com.quran.labs.androidquran.data.QuranInfo; + +import java.util.List; + +public class ShareUtil { + + public static void copyVerses(Activity activity, List verses) { + String text = getShareText(activity, verses); + ClipboardManager cm = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(activity.getString(R.string.app_name), text); + cm.setPrimaryClip(clip); + Toast.makeText(activity, activity.getString(R.string.ayah_copied_popup), + Toast.LENGTH_SHORT).show(); + } + + public static void shareVerses(Activity activity, List verses) { + String text = getShareText(activity, verses); + shareViaIntent(activity, text, R.string.share_ayah_text); + } + + public static void shareViaIntent(Activity activity, String text, @StringRes int titleResId) { + final Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, text); + activity.startActivity(Intent.createChooser(intent, activity.getString(titleResId))); + } + + private static String getShareText(Activity activity, List verses) { + final int size = verses.size(); + + StringBuilder sb = new StringBuilder("("); + for (int i = 0; i < size; i++) { + sb.append(verses.get(i).text); + if (i + 1 < size) { + sb.append(" * "); + } + } + + // append ) and a new line after last ayah + sb.append(")\n"); + // append [ before sura label + sb.append("["); + + final QuranText firstAyah = verses.get(0); + sb.append(QuranInfo.getSuraName(activity, firstAyah.sura, true)); + sb.append(" "); + sb.append(firstAyah.ayah); + if (size > 1) { + final QuranText lastAyah = verses.get(size - 1); + sb.append(" - "); + if (firstAyah.sura != lastAyah.sura) { + sb.append(QuranInfo.getSuraName(activity, lastAyah.sura, true)); + sb.append(" "); + } + sb.append(lastAyah.ayah); + } + // close sura label and append two new lines + sb.append("]\n\n"); + + sb.append(activity.getString(R.string.via_string)); + return sb.toString(); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/StorageUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/StorageUtils.java new file mode 100644 index 0000000000..4c9819b444 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/StorageUtils.java @@ -0,0 +1,291 @@ +package com.quran.labs.androidquran.util; + +import android.content.Context; +import android.os.Build; +import android.os.Environment; +import android.os.StatFs; +import android.support.v4.content.ContextCompat; + +import com.quran.labs.androidquran.R; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Scanner; +import java.util.Set; + +import timber.log.Timber; + + +/** + * Based on: + * - http://sapienmobile.com/?p=204 + * - http://stackoverflow.com/a/15612964 + * - http://renzhi.ca/2012/02/03/how-to-list-all-sd-cards-on-android/ + */ +public class StorageUtils { + + /** + * @return A List of all storage locations available + */ + public static List getAllStorageLocations(Context context) { + + /* + This first condition is the code moving forward, since the else case is a bunch + of unsupported hacks. + + For Kitkat and above, we rely on Environment.getExternalFilesDirs to give us a list + of application writable directories (none of which require WRITE_EXTERNAL_STORAGE on + Kitkat and above). + + Previously, we only would show anything if there were at least 2 entries. For M, + some changes were made, such that on M, we even show this if there is only one + entry. + + Irrespective of whether we require 1 entry (M) or 2 (Kitkat and L), we add an + additional entry explicitly for the sdcard itself, (the one requiring + WRITE_EXTERNAL_STORAGE to write). + + Thus, on Kitkat, the user may either: + a. not see any item (if there's only one entry returned by getExternalFilesDirs, we won't + show any options since it's the same sdcard and we have the permission and the user can't + revoke it pre-Kitkat), or + b. see 3+ items - /sdcard, and then at least 2 external fiels directories. + + on M, the user will always see at least 2 items (the external files dir and the actual + external storage directory), and potentially more (depending on how many items are returned + by getExternalFilesDirs). + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + List result = new ArrayList<>(); + int limit = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? 1 : 2; + final File[] mountPoints = ContextCompat.getExternalFilesDirs(context, null); + if (mountPoints != null && mountPoints.length >= limit) { + int typeId; + if (!Environment.isExternalStorageRemovable() || Environment.isExternalStorageEmulated()) { + typeId = R.string.prefs_sdcard_internal; + } else { + typeId = R.string.prefs_sdcard_external; + } + + int number = 1; + result.add(new Storage(context.getString(typeId, number), + Environment.getExternalStorageDirectory().getAbsolutePath(), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)); + for (File mountPoint : mountPoints) { + result.add(new Storage(context.getString(typeId, number++), + mountPoint.getAbsolutePath())); + typeId = R.string.prefs_sdcard_external; + } + } + return result; + } else { + return getLegacyStorageLocations(context); + } + } + + /** + * Attempt to return a list of storage locations pre-Kitkat. + * @param context the context + * @return the list of storage locations + */ + private static List getLegacyStorageLocations(Context context) { + List mounts = readMountsFile(); + + // As per http://source.android.com/devices/tech/storage/config.html + // device-specific vold.fstab file is removed after Android 4.2.2 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { + Set volds = readVoldsFile(); + + List toRemove = new ArrayList<>(); + for (String mount : mounts) { + if (!volds.contains(mount)) { + toRemove.add(mount); + } + } + + for (String s : toRemove) { + mounts.remove(s); + } + } else { + Timber.d("Android version: %d, skip reading vold.fstab file", Build.VERSION.SDK_INT); + } + + Timber.d("mounts list is: %s", mounts); + return buildMountsList(context, mounts); + } + + /** + * Converts a list of mount strings to a list of Storage items + * @param context the context + * @param mounts a list of mount points as strings + * @return a list of Storage items that can be rendered by the ui + */ + private static List buildMountsList(Context context, List mounts) { + List list = new ArrayList<>(mounts.size()); + + int externalSdcardsCount = 0; + if (mounts.size() > 0) { + // Follow Android SD Cards naming conventions + if (!Environment.isExternalStorageRemovable() || Environment.isExternalStorageEmulated()) { + list.add(new Storage(context.getString(R.string.prefs_sdcard_internal), + Environment.getExternalStorageDirectory().getAbsolutePath())); + } else { + externalSdcardsCount = 1; + list.add(new Storage(context.getString(R.string.prefs_sdcard_external, + externalSdcardsCount), mounts.get(0))); + } + + // All other mounts rather than the first mount point are considered as External SD Card + if (mounts.size() > 1) { + externalSdcardsCount++; + for (int i = 1/*skip the first item*/; i < mounts.size(); i++) { + list.add(new Storage(context.getString(R.string.prefs_sdcard_external, + externalSdcardsCount++), mounts.get(i))); + } + } + } + + Timber.d("final storage list is: %s", list); + return list; + } + + /** + * Read /proc/mounts. This is a set of hacks for versions below Kitkat. + * @return list of mounts based on the mounts file. + */ + private static List readMountsFile() { + String sdcardPath = Environment.getExternalStorageDirectory().getAbsolutePath(); + List mounts = new ArrayList<>(); + mounts.add(sdcardPath); + + Timber.d("reading mounts file begin"); + try { + File mountFile = new File("/proc/mounts"); + if (mountFile.exists()) { + Timber.d("mounts file exists"); + Scanner scanner = new Scanner(mountFile); + while (scanner.hasNext()) { + String line = scanner.nextLine(); + Timber.d("line: %s", line); + if (line.startsWith("/dev/block/vold/")) { + String[] lineElements = line.split(" "); + String element = lineElements[1]; + Timber.d("mount element is: %s", element); + if (!sdcardPath.equals(element)) { + mounts.add(element); + } + } else { + Timber.d("skipping mount line: %s", line); + } + } + } else { + Timber.d("mounts file doesn't exist"); + } + + Timber.d("reading mounts file end.. list is: %s", mounts); + } catch (Exception e) { + Timber.e(e, "Error reading mounts file"); + } + return mounts; + } + + /** + * Reads volume manager daemon file for auto-mounted storage. + * Read more about it here. + * + * Set usage, to safely avoid duplicates, is intentional. + * @return Set of mount points from `vold.fstab` configuration file + */ + private static Set readVoldsFile() { + Set volds = new HashSet<>(); + volds.add(Environment.getExternalStorageDirectory().getAbsolutePath()); + + Timber.d("reading volds file"); + try { + File voldFile = new File("/system/etc/vold.fstab"); + if (voldFile.exists()) { + Timber.d("reading volds file begin"); + Scanner scanner = new Scanner(voldFile); + while (scanner.hasNext()) { + String line = scanner.nextLine(); + Timber.d("line: %s", line); + if (line.startsWith("dev_mount")) { + String[] lineElements = line.split(" "); + String element = lineElements[2]; + Timber.d("volds element is: %s", element); + + if (element.contains(":")) { + element = element.substring(0, element.indexOf(":")); + Timber.d("volds element is: %s", element); + } + + Timber.d("adding volds element to list: %s", element); + volds.add(element); + } else { + Timber.d("skipping volds line: %s", line); + } + } + } else { + Timber.d("volds file doesn't exit"); + } + Timber.d("reading volds file end.. list is: %s", volds); + } catch (Exception e) { + Timber.e(e, "Error reading volds file"); + } + + return volds; + } + + public static class Storage { + private final String label; + private final String mountPoint; + private final boolean requiresPermission; + + private int freeSpace; + + Storage(String label, String mountPoint) { + this(label, mountPoint, false); + } + + Storage(String label, String mountPoint, boolean requiresPermission) { + this.label = label; + this.mountPoint = mountPoint; + this.requiresPermission = requiresPermission; + computeSpace(); + } + + private void computeSpace() { + StatFs stat = new StatFs(mountPoint); + long bytesAvailable; + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR1) { + bytesAvailable = stat.getAvailableBlocksLong() * stat.getBlockSizeLong(); + } else { + //noinspection deprecation + bytesAvailable = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); + } + // Convert total bytes to megabytes + freeSpace = Math.round(bytesAvailable / (1024 * 1024)); + } + + public String getLabel() { + return label; + } + + public String getMountPoint() { + return mountPoint; + } + + /** + * @return available free size in Megabytes + */ + public int getFreeSpace() { + return freeSpace; + } + + public boolean doesRequirePermission() { + return requiresPermission; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/TranslationUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/TranslationUtils.java new file mode 100644 index 0000000000..89a5f4baff --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/TranslationUtils.java @@ -0,0 +1,52 @@ +package com.quran.labs.androidquran.util; + +import com.quran.labs.androidquran.common.LocalTranslation; + +import android.content.Context; + +import java.util.List; + +public class TranslationUtils { + + public static String getDefaultTranslation(Context context, List items) { + final LocalTranslation item = getDefaultTranslationItem(context, items); + return item == null ? null : item.filename; + } + + public static LocalTranslation getDefaultTranslationItem(Context context, + List items) { + if (items == null || items.size() == 0) { + return null; + } + QuranSettings settings = QuranSettings.getInstance(context.getApplicationContext()); + final String db = settings.getActiveTranslation(); + + LocalTranslation result = null; + boolean changed = false; + if (db == null) { + changed = true; + result = items.get(0); + } else { + boolean found = false; + for (int i = 0, itemsSize = items.size(); i < itemsSize; i++) { + LocalTranslation item = items.get(i); + if (item.filename.equals(db)) { + found = true; + result = item; + break; + } + } + + if (!found) { + changed = true; + result = items.get(0); + } + } + + if (changed && result != null) { + settings.setActiveTranslation(result.filename); + } + + return result; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/util/ZipUtils.java b/app/src/main/java/com/quran/labs/androidquran/util/ZipUtils.java new file mode 100644 index 0000000000..66e52db712 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/util/ZipUtils.java @@ -0,0 +1,100 @@ +package com.quran.labs.androidquran.util; + +import android.support.annotation.VisibleForTesting; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import timber.log.Timber; + +public class ZipUtils { + + private static final int BUFFER_SIZE = 512; + private static final int MAX_FILES = 2048; // Max number of files + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static int MAX_UNZIPPED_SIZE = 0x1f400000; // Max size of unzipped data, 500MB + + /** + * Unzip a file given the file, an item, and the listener + * Does similar checks to those shown in rule 0's IDS04-J rule from: + * https://www.securecoding.cert.org/confluence/display/java + * @param zipFile the path to the zip file + * @param destDirectory the directory to extract the file in + * @param item any data object passed back to the listener + * @param listener a progress listener + * @param the type of the item passed in + * @return a boolean representing whether we succeeded to unzip the file or not + */ + public static boolean unzipFile(String zipFile, + String destDirectory, T item, ZipListener listener){ + try { + File file = new File(zipFile); + Timber.d("unzipping %s, size: %d", zipFile, file.length()); + + ZipFile zip = new ZipFile(file, ZipFile.OPEN_READ); + int numberOfFiles = zip.size(); + Enumeration entries = zip.entries(); + + String canonicalPath = new File(destDirectory).getCanonicalPath(); + + long total = 0; + int processedFiles = 0; + while (entries.hasMoreElements()) { + processedFiles++; + ZipEntry entry = entries.nextElement(); + + File currentEntryFile = new File(destDirectory, entry.getName()); + if (currentEntryFile.getCanonicalPath().startsWith(canonicalPath)) { + if (entry.isDirectory()) { + if (!currentEntryFile.exists()) { + currentEntryFile.mkdirs(); + } + continue; + } else if (currentEntryFile.exists()) { + // delete files that already exist + currentEntryFile.delete(); + } + + InputStream is = zip.getInputStream(entry); + FileOutputStream ostream = new FileOutputStream(currentEntryFile); + + int size; + byte[] buf = new byte[BUFFER_SIZE]; + while (total + BUFFER_SIZE <= MAX_UNZIPPED_SIZE && (size = is.read(buf)) > 0) { + ostream.write(buf, 0, size); + total += size; + } + is.close(); + ostream.close(); + + if (processedFiles >= MAX_FILES || total >= MAX_UNZIPPED_SIZE) { + throw new IllegalStateException("Invalid zip file."); + } + + if (listener != null) { + listener.onProcessingProgress(item, processedFiles, numberOfFiles); + } + } else { + throw new IllegalStateException("Invalid zip file."); + } + } + + zip.close(); + return true; + } + catch (IOException ioe) { + Timber.e(ioe, "Error unzipping file"); + return false; + } + } + + public interface ZipListener { + void onProcessingProgress(T obj, int processed, int total); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/AudioStatusBar.java b/app/src/main/java/com/quran/labs/androidquran/widgets/AudioStatusBar.java new file mode 100644 index 0000000000..810aea403b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/AudioStatusBar.java @@ -0,0 +1,539 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.os.Build; +import android.preference.PreferenceManager; +import android.support.annotation.DrawableRes; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QariItem; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.util.AudioUtils; +import com.quran.labs.androidquran.util.QuranScreenInfo; +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; + +import java.util.List; + +public class AudioStatusBar extends LeftToRightLinearLayout { + + public static final int STOPPED_MODE = 1; + public static final int DOWNLOADING_MODE = 2; + public static final int PLAYING_MODE = 3; + public static final int PAUSED_MODE = 4; + public static final int PROMPT_DOWNLOAD_MODE = 5; + + private Context context; + private int currentMode; + private int buttonWidth; + private int separatorWidth; + private int separatorSpacing; + private int textFontSize; + private int textFullFontSize; + private int spinnerPadding; + private QariAdapter adapter; + + private int currentQari; + private int currentRepeat = 0; + @DrawableRes private int itemBackground; + private boolean isRtl; + private boolean isTablet; + private boolean hasErrorText; + private boolean haveCriticalError = false; + private SharedPreferences sharedPreferences; + + private QuranSpinner spinner; + private TextView progressText; + private ProgressBar progressBar; + private RepeatButton repeatButton; + private AudioBarListener audioBarListener; + + private int[] repeatValues = {0, 1, 2, 3, -1}; + + public interface AudioBarListener { + void onPlayPressed(); + void onPausePressed(); + void onNextPressed(); + void onPreviousPressed(); + void onStopPressed(); + void onCancelPressed(boolean stopDownload); + void setRepeatCount(int repeatCount); + void onAcceptPressed(); + void onAudioSettingsPressed(); + } + + public AudioStatusBar(Context context) { + this(context, null); + } + + public AudioStatusBar(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AudioStatusBar(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + this.context = context; + Resources resources = getResources(); + buttonWidth = resources.getDimensionPixelSize( + R.dimen.audiobar_button_width); + separatorWidth = resources.getDimensionPixelSize( + R.dimen.audiobar_separator_width); + separatorSpacing = resources.getDimensionPixelSize( + R.dimen.audiobar_separator_padding); + textFontSize = resources.getDimensionPixelSize( + R.dimen.audiobar_text_font_size); + textFullFontSize = resources.getDimensionPixelSize( + R.dimen.audiobar_text_full_font_size); + spinnerPadding = resources + .getDimensionPixelSize(R.dimen.audiobar_spinner_padding); + setOrientation(LinearLayout.HORIZONTAL); + + // only flip the layout when the language is rtl and we're on api 17+ + isRtl = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && + (QuranSettings.getInstance(this.context).isArabicNames() || QuranUtils.isRtl()); + isTablet = QuranScreenInfo.getOrMakeInstance(this.context).isTablet(this.context); + sharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context.getApplicationContext()); + currentQari = sharedPreferences.getInt(Constants.PREF_DEFAULT_QARI, 0); + + itemBackground = 0; + if (attrs != null) { + TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.AudioStatusBar); + itemBackground = ta.getResourceId(R.styleable.AudioStatusBar_android_itemBackground, + itemBackground); + ta.recycle(); + } + + List qariList = AudioUtils.getQariList(this.context); + + // TODO: optimize - PREF_DEFAULT_QARI is the qari id, should introduce a helper pref for pos + final int qaris = qariList.size(); + if (currentQari >= qaris || qariList.get(currentQari).getId() != currentQari) { + // figure out the updated position for the index + int updatedIndex = 0; + for (int i = 0; i < qaris; i++) { + if (qariList.get(i).getId() == currentQari) { + updatedIndex = i; + break; + } + } + currentQari = updatedIndex; + } + + adapter = new QariAdapter(this.context, qariList, + R.layout.sherlock_spinner_item, R.layout.sherlock_spinner_dropdown_item); + showStoppedMode(); + } + + public int getCurrentMode() { + return currentMode; + } + + public void switchMode(int mode) { + if (mode == currentMode) { + return; + } + + if (mode == STOPPED_MODE) { + showStoppedMode(); + } else if (mode == PROMPT_DOWNLOAD_MODE) { + showPromptForDownloadMode(); + } else if (mode == DOWNLOADING_MODE) { + showDownloadingMode(); + } else if (mode == PLAYING_MODE) { + showPlayingMode(false); + } else { + showPlayingMode(true); + } + } + + @NonNull + public QariItem getAudioInfo() { + final int position = spinner != null ? spinner.getSelectedItemPosition() : currentQari; + return adapter.getItem(position); + } + + public void updateSelectedItem() { + if (spinner != null) { + spinner.setSelection(currentQari); + } + } + + public void setProgress(int progress) { + if (hasErrorText) { + progressText.setText(R.string.downloading_title); + hasErrorText = false; + } + + if (progressBar != null) { + if (progress >= 0) { + progressBar.setIndeterminate(false); + progressBar.setProgress(progress); + progressBar.setMax(100); + } else { + progressBar.setIndeterminate(true); + } + } + } + + public void setProgressText(String progressText, boolean isCriticalError) { + if (this.progressText != null) { + hasErrorText = true; + this.progressText.setText(progressText); + if (isCriticalError && progressBar != null) { + progressBar.setVisibility(View.GONE); + this.progressText.setTextSize(TypedValue.COMPLEX_UNIT_PX, + textFullFontSize); + haveCriticalError = true; + } + } + } + + private void showStoppedMode() { + currentMode = STOPPED_MODE; + removeAllViews(); + + if (isRtl) { + addSpinner(); + addSeparator(); + addButton(R.drawable.ic_play, false); + } else { + addButton(R.drawable.ic_play, false); + addSeparator(); + addSpinner(); + } + } + + private static class QariAdapter extends BaseAdapter { + @NonNull LayoutInflater mInflater; + @NonNull private final List mItems; + @LayoutRes private final int mLayoutViewId; + @LayoutRes private final int mDropDownViewId; + + QariAdapter(@NonNull Context context, + @NonNull List items, + @LayoutRes int layoutViewId, + @LayoutRes int dropDownViewId) { + mItems = items; + mLayoutViewId = layoutViewId; + mDropDownViewId = dropDownViewId; + mInflater = LayoutInflater.from(context); + } + + @Override + public int getCount() { + return mItems.size(); + } + + @Override + public QariItem getItem(int position) { + return mItems.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + return getViewInternal(position, convertView, parent, mLayoutViewId); + } + + @Override + public View getDropDownView(int position, View convertView, ViewGroup parent) { + return getViewInternal(position, convertView, parent, mDropDownViewId); + } + + private View getViewInternal(int position, View convertView, + ViewGroup parent, @LayoutRes int resource) { + TextView textView; + if (convertView == null) { + textView = (TextView) mInflater.inflate(resource, parent, false); + } else { + textView = (TextView) convertView; + } + + QariItem item = getItem(position); + textView.setText(item.getName()); + return textView; + } + } + + private void addSpinner() { + if (spinner == null) { + spinner = new QuranSpinner(context, null, + R.attr.actionDropDownStyle); + spinner.setDropDownVerticalOffset(spinnerPadding); + spinner.setAdapter(adapter); + + spinner.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (position != currentQari) { + sharedPreferences.edit(). + putInt(Constants.PREF_DEFAULT_QARI, adapter.getItem(position).getId()).apply(); + currentQari = position; + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + spinner.setSelection(currentQari); + final LayoutParams params = new LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT); + params.weight = 1; + if (isRtl) { + ViewCompat.setLayoutDirection(spinner, ViewCompat.LAYOUT_DIRECTION_RTL); + params.leftMargin = spinnerPadding; + } else { + params.rightMargin = spinnerPadding; + } + addView(spinner, params); + } + + private void showPromptForDownloadMode() { + currentMode = PROMPT_DOWNLOAD_MODE; + + removeAllViews(); + + if (isRtl) { + addButton(R.drawable.ic_cancel, false); + addDownloadOver3gPrompt(); + addSeparator(); + addButton(R.drawable.ic_accept, false); + } else { + addButton(R.drawable.ic_accept, false); + addSeparator(); + addDownloadOver3gPrompt(); + addButton(R.drawable.ic_cancel, false); + } + } + + private void addDownloadOver3gPrompt() { + TextView mPromptText = new TextView(context); + mPromptText.setTextColor(Color.WHITE); + mPromptText.setGravity(Gravity.CENTER_VERTICAL); + mPromptText.setTextSize(TypedValue.COMPLEX_UNIT_PX, + textFontSize); + mPromptText.setText(R.string.download_non_wifi_prompt); + LayoutParams params = new LayoutParams(0, + LayoutParams.MATCH_PARENT); + params.weight = 1; + addView(mPromptText, params); + } + + private void showDownloadingMode() { + currentMode = DOWNLOADING_MODE; + + removeAllViews(); + + if (isRtl) { + addDownloadProgress(); + addSeparator(); + addButton(R.drawable.ic_cancel, false); + } else { + addButton(R.drawable.ic_cancel, false); + addSeparator(); + addDownloadProgress(); + } + } + + private void addDownloadProgress() { + LinearLayout ll = new LinearLayout(context); + ll.setOrientation(LinearLayout.VERTICAL); + + progressBar = (ProgressBar) LayoutInflater.from(context) + .inflate(R.layout.download_progress_bar, this, false); + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + + ll.addView(progressBar, LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT); + + progressText = new TextView(context); + progressText.setTextColor(Color.WHITE); + progressText.setGravity(Gravity.CENTER_VERTICAL); + progressText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textFontSize); + progressText.setText(R.string.downloading_title); + + ll.addView(progressText, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + + LinearLayout.LayoutParams lp = + new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT); + lp.weight = 1; + lp.setMargins(separatorSpacing, 0, separatorSpacing, 0); + if (isRtl) { + lp.leftMargin = spinnerPadding; + } else { + lp.rightMargin = spinnerPadding; + } + addView(ll, lp); + } + + private void showPlayingMode(boolean isPaused) { + removeAllViews(); + + final boolean withWeight = !isTablet; + + int button; + if (isPaused) { + button = R.drawable.ic_play; + currentMode = PAUSED_MODE; + } else { + button = R.drawable.ic_pause; + currentMode = PLAYING_MODE; + } + + addButton(R.drawable.ic_stop, withWeight); + addButton(R.drawable.ic_previous, withWeight); + addButton(button, withWeight); + addButton(R.drawable.ic_next, withWeight); + + repeatButton = new RepeatButton(context); + addButton(repeatButton, R.drawable.ic_repeat, withWeight); + updateRepeatButtonText(); + + addButton(R.drawable.ic_action_settings, withWeight); + } + + private void addButton(int imageId, boolean withWeight) { + addButton(new ImageView(context), imageId, withWeight); + } + + private void addButton(@NonNull ImageView button, int imageId, boolean withWeight) { + button.setImageResource(imageId); + button.setScaleType(ImageView.ScaleType.CENTER); + button.setOnClickListener(mOnClickListener); + button.setTag(imageId); + button.setBackgroundResource(itemBackground); + final LayoutParams params = new LayoutParams( + withWeight ? 0 : buttonWidth, LayoutParams.MATCH_PARENT); + if (withWeight) { + params.weight = 1; + } + addView(button, params); + } + + private void addSeparator() { + ImageView separator = new ImageView(context); + separator.setBackgroundColor(Color.WHITE); + separator.setPadding(0, separatorSpacing, 0, separatorSpacing); + LinearLayout.LayoutParams paddingParams = + new LayoutParams(separatorWidth, LayoutParams.MATCH_PARENT); + + final int right = isRtl ? 0 : separatorSpacing; + final int left = isRtl ? separatorSpacing : 0; + paddingParams.setMargins(left, 0, right, 0); + addView(separator, paddingParams); + } + + private void incrementRepeat() { + currentRepeat++; + if (currentRepeat == repeatValues.length) { + currentRepeat = 0; + } + updateRepeatButtonText(); + } + + private void updateRepeatButtonText() { + String str; + int value = repeatValues[currentRepeat]; + if (value == 0) { + str = ""; + } else if (value > 0) { + str = repeatValues[currentRepeat] + ""; + } else { + str = context.getString(R.string.infinity); + } + repeatButton.setText(str); + } + + public void setRepeatCount(int repeatCount) { + boolean updated = false; + for (int i = 0; i < repeatValues.length; i++) { + if (repeatValues[i] == repeatCount) { + if (currentRepeat != i) { + currentRepeat = i; + updated = true; + } + break; + } + } + + if (updated && repeatButton != null) { + updateRepeatButtonText(); + } + } + + public void setAudioBarListener(AudioBarListener listener) { + audioBarListener = listener; + } + + OnClickListener mOnClickListener = new OnClickListener() { + @Override + public void onClick(View view) { + if (audioBarListener != null) { + int tag = (Integer) view.getTag(); + switch (tag) { + case R.drawable.ic_play: + audioBarListener.onPlayPressed(); + break; + case R.drawable.ic_stop: + audioBarListener.onStopPressed(); + break; + case R.drawable.ic_pause: + audioBarListener.onPausePressed(); + break; + case R.drawable.ic_next: + audioBarListener.onNextPressed(); + break; + case R.drawable.ic_previous: + audioBarListener.onPreviousPressed(); + break; + case R.drawable.ic_repeat: + incrementRepeat(); + audioBarListener.setRepeatCount(repeatValues[currentRepeat]); + break; + case R.drawable.ic_cancel: + if (haveCriticalError) { + haveCriticalError = false; + switchMode(STOPPED_MODE); + } else { + audioBarListener.onCancelPressed(currentMode != PROMPT_DOWNLOAD_MODE); + } + break; + case R.drawable.ic_accept: + audioBarListener.onAcceptPressed(); + break; + case R.drawable.ic_action_settings: + audioBarListener.onAudioSettingsPressed(); + break; + } + } + } + }; +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/AyahNumberView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/AyahNumberView.java new file mode 100644 index 0000000000..f0ddfab55e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/AyahNumberView.java @@ -0,0 +1,94 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.view.View; + +import com.quran.labs.androidquran.R; + +public class AyahNumberView extends View { + private int boxColor; + private int nightBoxColor; + private int boxWidth; + private int boxHeight; + private int padding; + private int textSize; + private String suraAyah; + private boolean isNightMode; + + private Paint boxPaint; + private TextPaint textPaint; + private StaticLayout textLayout; + + public AyahNumberView(Context context) { + this(context, null); + } + + public AyahNumberView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + + int textColor = 0; + if (attrs != null) { + TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.AyahNumberView); + textColor = ta.getColor(R.styleable.AyahNumberView_android_textColor, textColor); + boxColor = ta.getColor(R.styleable.AyahNumberView_backgroundColor, boxColor); + nightBoxColor = ta.getColor(R.styleable.AyahNumberView_nightBackgroundColor, nightBoxColor); + boxWidth = ta.getDimensionPixelSize(R.styleable.AyahNumberView_verseBoxWidth, boxWidth); + boxHeight = ta.getDimensionPixelSize(R.styleable.AyahNumberView_verseBoxHeight, boxHeight); + textSize = ta.getDimensionPixelSize(R.styleable.AyahNumberView_android_textSize, textSize); + ta.recycle(); + } + + boxPaint = new Paint(); + boxPaint.setColor(boxColor); + textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + textPaint.setColor(textColor); + textPaint.setTextSize(textSize); + } + + public void setAyahString(@NonNull String suraAyah) { + if (!suraAyah.equals(this.suraAyah)) { + this.suraAyah = suraAyah; + this.textLayout = new StaticLayout(suraAyah, textPaint, boxWidth, + Layout.Alignment.ALIGN_CENTER, 1.0f, 0.0f, false); + invalidate(); + } + } + + public void setNightMode(boolean isNightMode) { + if (this.isNightMode != isNightMode) { + boxPaint.setColor(isNightMode ? nightBoxColor : boxColor); + this.isNightMode = isNightMode; + invalidate(); + } + } + + public void setTextColor(int textColor) { + textPaint.setColor(textColor); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + padding = (getMeasuredHeight() - boxHeight) / 2; + } + + @Override + protected void onDraw(Canvas canvas) { + canvas.drawRect(padding, padding, padding + boxWidth, padding + boxHeight, boxPaint); + if (this.textLayout != null) { + int startY = padding + ((boxHeight - this.textLayout.getHeight()) / 2); + canvas.translate(padding, startY); + this.textLayout.draw(canvas); + canvas.translate(padding, -startY); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/AyahToolBar.java b/app/src/main/java/com/quran/labs/androidquran/widgets/AyahToolBar.java new file mode 100644 index 0000000000..4f7a1d6324 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/AyahToolBar.java @@ -0,0 +1,238 @@ +package com.quran.labs.androidquran.widgets; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Resources; +import android.os.Build; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.PopupMenu; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.Toast; + +import com.quran.labs.androidquran.R; + +public class AyahToolBar extends ViewGroup implements + View.OnClickListener, View.OnLongClickListener { + public enum PipPosition { UP, DOWN } + + private Context context; + private Menu menu; + private Menu currentMenu; + private int itemWidth; + private int pipWidth; + private int pipHeight; + private boolean isShowing; + private float pipOffset; + private LinearLayout menuLayout; + private AyahToolBarPip toolBarPip; + private PipPosition pipPosition; + private MenuItem.OnMenuItemClickListener itemSelectedListener; + + public AyahToolBar(Context context) { + this(context, null); + } + + public AyahToolBar(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public AyahToolBar(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context); + } + + private void init(Context context) { + this.context = context; + final Resources resources = context.getResources(); + itemWidth = resources.getDimensionPixelSize(R.dimen.toolbar_item_width); + final int toolBarHeight = resources.getDimensionPixelSize(R.dimen.toolbar_height); + pipHeight = resources.getDimensionPixelSize(R.dimen.toolbar_pip_height); + pipWidth = resources.getDimensionPixelSize(R.dimen.toolbar_pip_width); + final int background = ContextCompat.getColor(context, R.color.toolbar_background); + + menuLayout = new LinearLayout(context); + menuLayout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, toolBarHeight)); + menuLayout.setBackgroundColor(background); + addView(menuLayout); + + pipPosition = PipPosition.DOWN; + toolBarPip = new AyahToolBarPip(context); + toolBarPip.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, pipHeight)); + addView(toolBarPip); + + // used to use MenuBuilder, but now it has @RestrictTo, so using this clever trick from + // StackOverflow - PopupMenu generates a new MenuBuilder internally, so this just lets us + // get that menu and do whatever we want with it. + menu = new PopupMenu(this.context, this).getMenu(); + final MenuInflater inflater = new MenuInflater(this.context); + inflater.inflate(R.menu.ayah_menu, menu); + showMenu(menu); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int totalWidth = getMeasuredWidth(); + final int pipWidth = toolBarPip.getMeasuredWidth(); + final int pipHeight = toolBarPip.getMeasuredHeight(); + final int menuWidth = menuLayout.getMeasuredWidth(); + final int menuHeight = menuLayout.getMeasuredHeight(); + + int pipLeft = (int) pipOffset; + if ((pipLeft + pipWidth) > totalWidth) { + pipLeft = (totalWidth / 2) - (pipWidth / 2); + } + + // overlap the pip and toolbar by 1px to avoid occasional gap + if (pipPosition == PipPosition.UP) { + toolBarPip.layout(pipLeft, 0, pipLeft + pipWidth, pipHeight + 1); + menuLayout.layout(0, pipHeight, menuWidth, pipHeight + menuHeight); + } else { + toolBarPip.layout(pipLeft, menuHeight - 1, pipLeft + pipWidth, menuHeight + pipHeight); + menuLayout.layout(0, 0, menuWidth, menuHeight); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + measureChild(menuLayout, widthMeasureSpec, heightMeasureSpec); + final int width = menuLayout.getMeasuredWidth(); + int height = menuLayout.getMeasuredHeight(); + measureChild(toolBarPip, + MeasureSpec.makeMeasureSpec(pipWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(pipHeight, MeasureSpec.EXACTLY)); + height += toolBarPip.getMeasuredHeight(); + setMeasuredDimension(resolveSize(width, widthMeasureSpec), + resolveSize(height, heightMeasureSpec)); + } + + private void showMenu(Menu menu) { + if (currentMenu == menu) { + // no need to re-draw + return; + } + + menuLayout.removeAllViews(); + final int count = menu.size(); + for (int i=0; i= Build.VERSION_CODES.JELLY_BEAN_MR1) { + // needed to fix positioning of the ayah toolbar + setLayoutDirection(LAYOUT_DIRECTION_LTR); + } + } + + @Override + protected boolean fitSystemWindows(@NonNull Rect insets) { + if (toolBarViewParams == null || audioBarViewParams == null) { + View toolbar = findViewById(R.id.toolbar); + toolBarParent = (View) toolbar.getParent(); + toolBarViewParams = (MarginLayoutParams) toolbar.getLayoutParams(); + audioBarViewParams = (MarginLayoutParams) findViewById(R.id.audio_area).getLayoutParams(); + } + + toolBarViewParams.setMargins(insets.left, insets.top, insets.right, 0); + audioBarViewParams.setMargins(insets.left, 0, insets.right, insets.bottom); + + /* + this is needed to fix a bug where the Toolbar is half cut off before Kitkat (especially when + playing audio). + + the reason for this is that we always animate the Toolbar's parent to either 0 or to + the negative value of its height, and on pre-Kitkat, the parent's height is incorrect (not + reflecting the updated top margin on the toolbar itself), unless we explicitly + requestLayout. the reason for this is that on Kitkat and above, the insets don't change when + the toolbar is shown or hidden whereas pre-Kitkat, the inset's top value is 0 when the + toolbar is gone and some value when the toolbar is visible. + + the audio bar solves this same problem by animating to its height plus its bottomMargin, + (partially because the audio bar does not have a parent wrapper that is being animated, and, + as a result, by definition, will never have its height reflect any updated margins). it is + possible for the toolbar to solve the problem in the same way as well (by using the + topMargin in addition to the height of the parent). + */ + if (IS_PRE_KITKAT && lastTopInset != insets.top) { + toolBarParent.requestLayout(); + lastTopInset = insets.top; + } + return true; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/ForceCompleteTextView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/ForceCompleteTextView.java new file mode 100644 index 0000000000..6ee0b57af1 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/ForceCompleteTextView.java @@ -0,0 +1,68 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.graphics.Rect; +import android.support.v7.widget.AppCompatAutoCompleteTextView; +import android.util.AttributeSet; +import android.widget.AdapterView; + +/** + * AutoCompleteTextView that forces to use value from one of the values in adapter (choices). + */ +public class ForceCompleteTextView extends AppCompatAutoCompleteTextView { + /* Thanks to those in http://stackoverflow.com/q/15544943/1197317 for inspiration */ + + public ForceCompleteTextView(Context context) { + super(context); + } + + public ForceCompleteTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ForceCompleteTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + // TODO create relevant listener name, such as onSelectChoice + AdapterView.OnItemClickListener listener = getOnItemClickListener(); + if (listener != null) + listener.onItemClick(null, null, -1, -1); + } + + @Override + public boolean enoughToFilter() { + // Break the limit of minimum 1 + return true; + } + + @Override + protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(focused, direction, previouslyFocusedRect); + if (focused) { + performFiltering(getText(), 0); + } else { + // TODO create relevant listener name, such as onSelectChoice + AdapterView.OnItemClickListener listener = getOnItemClickListener(); + if (listener != null) + listener.onItemClick(null, null, -1, -1); + } + } + + /** + * Sets the listener that will be notified when the user clicks an item in the drop down list, + * user leaves without clicking, or when this view is attached to window. The two latter cases you + * use to force the completion, the listener will be called with position argument set to -1 and + * view argument set to null. + * + * @param l the item click listener + */ + @Override + public void setOnItemClickListener(AdapterView.OnItemClickListener l) { + super.setOnItemClickListener(l); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/HighlightingImageView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/HighlightingImageView.java new file mode 100644 index 0000000000..a1b13c5964 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/HighlightingImageView.java @@ -0,0 +1,315 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorMatrixColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Paint.FontMetrics; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.widget.ImageView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.AyahBounds; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.ui.helpers.HighlightType; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +public class HighlightingImageView extends ImageView { + + private static final SparseArray SPARSE_PAINT_ARRAY = new SparseArray<>(); + + private static int overlayTextColor = -1; + private static int headerFooterSize; + private static int headerFooterFontSize; + private static int scrollableHeaderFooterSize; + private static int scrollableHeaderFooterFontSize; + + // Sorted map so we use highest priority highlighting when iterating + private SortedMap> currentHighlights = new TreeMap<>(); + + private boolean isNightMode; + private boolean isColorFilterOn; + private int nightModeTextBrightness = Constants.DEFAULT_NIGHT_MODE_TEXT_BRIGHTNESS; + + // cached objects for onDraw + private final RectF scaledRect = new RectF(); + private final Set alreadyHighlighted = new HashSet<>(); + + // Params for drawing text + private int fontSize; + private OverlayParams overlayParams = null; + private RectF pageBounds = null; + private boolean didDraw = false; + private Map> coordinatesData; + + public HighlightingImageView(Context context) { + this(context, null); + } + + public HighlightingImageView(Context context, AttributeSet attrs) { + super(context, attrs); + if (overlayTextColor == -1) { + final Resources res = context.getResources(); + overlayTextColor = ContextCompat.getColor(context, R.color.overlay_text_color); + headerFooterSize = res.getDimensionPixelSize(R.dimen.page_overlay_size); + scrollableHeaderFooterSize = res.getDimensionPixelSize(R.dimen.page_overlay_size_scrollable); + headerFooterFontSize = res.getDimensionPixelSize(R.dimen.page_overlay_font_size); + scrollableHeaderFooterFontSize = + res.getDimensionPixelSize(R.dimen.page_overlay_font_size_scrollable); + } + } + + public void setIsScrollable(boolean scrollable) { + int topBottom = scrollable ? scrollableHeaderFooterSize : headerFooterSize; + setPadding(getPaddingLeft(), topBottom, getPaddingRight(), topBottom); + fontSize = scrollable ? scrollableHeaderFooterFontSize : headerFooterFontSize; + } + + public void unHighlight(int sura, int ayah, HighlightType type) { + Set highlights = currentHighlights.get(type); + if (highlights != null && highlights.remove(sura + ":" + ayah)) { + invalidate(); + } + } + + public void highlightAyat(Set ayahKeys, HighlightType type) { + Set highlights = currentHighlights.get(type); + if (highlights == null) { + highlights = new HashSet<>(); + currentHighlights.put(type, highlights); + } + highlights.addAll(ayahKeys); + } + + public void unHighlight(HighlightType type) { + if (!currentHighlights.isEmpty()) { + currentHighlights.remove(type); + invalidate(); + } + } + + public void setCoordinateData(Map> data) { + coordinatesData = data; + } + + public void setNightMode(boolean isNightMode, int textBrightness) { + this.isNightMode = isNightMode; + if (isNightMode) { + nightModeTextBrightness = textBrightness; + // we need a new color filter now + isColorFilterOn = false; + } + adjustNightMode(); + } + + public void highlightAyah(int sura, int ayah, HighlightType type) { + Set highlights = currentHighlights.get(type); + if (highlights == null) { + highlights = new HashSet<>(); + currentHighlights.put(type, highlights); + } else if (!type.isMultipleHighlightsAllowed()) { + // If multiple highlighting not allowed (e.g. audio) + // clear all others of this type first + highlights.clear(); + } + highlights.add(sura + ":" + ayah); + } + + @Override + public void setImageDrawable(Drawable bitmap) { + // clear the color filter before setting the image + clearColorFilter(); + // this allows the filter to be enabled again if needed + isColorFilterOn = false; + + super.setImageDrawable(bitmap); + if (bitmap != null) { + adjustNightMode(); + } + } + + public void adjustNightMode() { + if (isNightMode && !isColorFilterOn) { + float[] matrix = { + -1, 0, 0, 0, nightModeTextBrightness, + 0, -1, 0, 0, nightModeTextBrightness, + 0, 0, -1, 0, nightModeTextBrightness, + 0, 0, 0, 1, 0 + }; + setColorFilter(new ColorMatrixColorFilter(matrix)); + isColorFilterOn = true; + } else if (!isNightMode) { + clearColorFilter(); + isColorFilterOn = false; + } + + invalidate(); + } + + private static class OverlayParams { + boolean init = false; + Paint paint = null; + float offsetX; + float topBaseline; + float bottomBaseline; + String suraText = null; + String juzText = null; + String pageText = null; + String rub3Text = null; + } + + public void setOverlayText(String suraText, String juzText, String pageText, String rub3Text) { + // Calculate page bounding rect from ayahinfo db + if (pageBounds == null) { + return; + } + + overlayParams = new OverlayParams(); + overlayParams.suraText = suraText; + overlayParams.juzText = juzText; + overlayParams.pageText = pageText; + overlayParams.rub3Text = rub3Text; + overlayParams.paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG); + overlayParams.paint.setTextSize(fontSize); + + if (!didDraw) { + invalidate(); + } + } + + public void setPageBounds(RectF rect) { + pageBounds = rect; + } + + private boolean initOverlayParams(Matrix matrix) { + if (overlayParams == null || pageBounds == null) { + return false; + } + + // Overlay params previously initiated; skip + if (overlayParams.init) { + return true; + } + + int overlayColor = overlayTextColor; + if (isNightMode) { + overlayColor = Color.rgb(nightModeTextBrightness, + nightModeTextBrightness, nightModeTextBrightness); + } + overlayParams.paint.setColor(overlayColor); + + // Use font metrics to calculate the maximum possible height of the text + FontMetrics fm = overlayParams.paint.getFontMetrics(); + + final RectF mappedRect = new RectF(); + matrix.mapRect(mappedRect, pageBounds); + + // Calculate where the text's baseline should be + // (for top text and bottom text) + // (p.s. parts of the glyphs will be below the baseline such as a + // 'y' or 'ي') + overlayParams.topBaseline = -fm.top; + overlayParams.bottomBaseline = getHeight() - fm.bottom; + + // Calculate the horizontal margins off the edge of screen + overlayParams.offsetX = Math.min( + mappedRect.left, getWidth() - mappedRect.right); + + overlayParams.init = true; + return true; + } + + private void overlayText(Canvas canvas, Matrix matrix) { + if (overlayParams == null || !initOverlayParams(matrix)) { + return; + } + + overlayParams.paint.setTextAlign(Align.LEFT); + canvas.drawText(overlayParams.suraText, + overlayParams.offsetX, overlayParams.topBaseline, + overlayParams.paint); + overlayParams.paint.setTextAlign(Align.CENTER); + canvas.drawText(overlayParams.pageText, + getWidth() / 2.0f, overlayParams.bottomBaseline, + overlayParams.paint); + // Merge the current rub3 text with the juz' text + overlayParams.paint.setTextAlign(Align.RIGHT); + canvas.drawText(overlayParams.juzText + overlayParams.rub3Text, + getWidth() - overlayParams.offsetX, overlayParams.topBaseline, + overlayParams.paint); + didDraw = true; + } + + private Paint getPaintForHighlightType(HighlightType type) { + int color = type.getColor(getContext()); + Paint paint = SPARSE_PAINT_ARRAY.get(color); + if (paint == null) { + paint = new Paint(); + paint.setColor(color); + SPARSE_PAINT_ARRAY.put(color, paint); + } + return paint; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (overlayParams != null) { + overlayParams.init = false; + } + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + final Drawable d = getDrawable(); + if (d == null) { + // no image, forget it. + return; + } + + final Matrix matrix = getImageMatrix(); + + // Draw overlay text + didDraw = false; + if (overlayParams != null) { + overlayText(canvas, matrix); + } + + // Draw each ayah highlight + if (coordinatesData != null && !currentHighlights.isEmpty()) { + alreadyHighlighted.clear(); + for (Map.Entry> entry : currentHighlights.entrySet()) { + Paint paint = getPaintForHighlightType(entry.getKey()); + for (String ayah : entry.getValue()) { + if (alreadyHighlighted.contains(ayah)) continue; + List rangesToDraw = coordinatesData.get(ayah); + if (rangesToDraw != null && !rangesToDraw.isEmpty()) { + for (AyahBounds b : rangesToDraw) { + matrix.mapRect(scaledRect, b.getBounds()); + scaledRect.offset(0, getPaddingTop()); + canvas.drawRect(scaledRect, paint); + } + alreadyHighlighted.add(ayah); + } + } + } + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/IconPageIndicator.java b/app/src/main/java/com/quran/labs/androidquran/widgets/IconPageIndicator.java new file mode 100644 index 0000000000..077c7084b0 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/IconPageIndicator.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * Copyright (C) 2012 Jake Wharton + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modifications: + * - use normal LinearLayout instead of IcsLinearLayout + * - remove dependency on PageIndicator interface + * - notifyDataSetChanged(): + * - use actionButtonStyle to give icons button-like padding and selector + * - add click listener (and tag) and onClick update the view pager + * - constructor: initialize the indicator stuff + * - onDraw(): draw indicator below the selected item + */ + +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v4.view.ViewPager.OnPageChangeListener; +import android.util.AttributeSet; +import android.view.View; +import android.widget.HorizontalScrollView; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import com.quran.labs.androidquran.R; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; + +/** + * This widget implements the dynamic action bar tab behavior that can change + * across different configurations or circumstances. + */ +public class IconPageIndicator extends HorizontalScrollView implements + ViewPager.OnPageChangeListener, View.OnClickListener { + + private static final int DEF_INDICATOR_HEIGHT = 3; // dp + private static final int DEF_INDICATOR_COLOR = Color.WHITE; + + private final LinearLayout mIconsLayout; + + private ViewPager mViewPager; + private OnPageChangeListener mListener; + private OnClickListener mClickListener; + private Runnable mIconSelector; + private int mSelectedIndex; + + private float mSelectionOffset; + private int mIndicatorColor; + private int mIndicatorHeight; + private final Paint mIndicatorPaint = new Paint(); + + public IconPageIndicator(Context context) { + this(context, null); + } + + public IconPageIndicator(Context context, AttributeSet attrs) { + super(context, attrs); + setHorizontalScrollBarEnabled(false); + + mIconsLayout = new LeftToRightLinearLayout(context); + addView(mIconsLayout, new LayoutParams(WRAP_CONTENT, MATCH_PARENT)); + + // Set indicator attributes (to defaults) + final float density = context.getResources().getDisplayMetrics().density; + mIndicatorHeight = (int) (DEF_INDICATOR_HEIGHT * density + 0.5f); + mIndicatorColor = DEF_INDICATOR_COLOR; + // Read the user-specified values if set, otherwise use defaults + if (attrs != null) { + TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.IconPageIndicator); + mIndicatorHeight = ta.getDimensionPixelSize( + R.styleable.IconPageIndicator_indicatorHeight, mIndicatorHeight); + mIndicatorColor = ta.getColor( + R.styleable.IconPageIndicator_indicatorColor, mIndicatorColor); + ta.recycle(); + } + mIndicatorPaint.setColor(mIndicatorColor); + } + + private void animateToIcon(final int position) { + final View iconView = mIconsLayout.getChildAt(position); + if (mIconSelector != null) { + removeCallbacks(mIconSelector); + } + mIconSelector = new Runnable() { + public void run() { + final int scrollPos = iconView.getLeft() - (getWidth() - iconView.getWidth()) / 2; + smoothScrollTo(scrollPos, 0); + mIconSelector = null; + } + }; + post(mIconSelector); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + if (mIconSelector != null) { + // Re-post the selector we saved + post(mIconSelector); + } + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mIconSelector != null) { + removeCallbacks(mIconSelector); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mListener != null) { + mListener.onPageScrollStateChanged(state); + } + } + + @Override + public void onPageScrolled(int position, float offset, int offsetPixels) { + mSelectionOffset = offset; + mSelectedIndex = position; + invalidate(); + if (mListener != null) { + mListener.onPageScrolled(position, offset, offsetPixels); + } + } + + @Override + public void onPageSelected(int position) { + setCurrentItem(position); + if (mListener != null) { + mListener.onPageSelected(position); + } + } + + public void setViewPager(ViewPager view) { + if (mViewPager == view) { + return; + } + if (mViewPager != null) { + mViewPager.addOnPageChangeListener(null); + } + PagerAdapter adapter = view.getAdapter(); + if (adapter == null) { + throw new IllegalStateException("ViewPager does not have adapter instance."); + } + mViewPager = view; + view.addOnPageChangeListener(this); + notifyDataSetChanged(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + int count = mIconsLayout.getChildCount(); + ImageView v = (ImageView) mIconsLayout.getChildAt(mSelectedIndex); + final int bottom = v.getHeight(); + final int top = bottom - mIndicatorHeight; + int left = v.getLeft(); + int right = v.getRight(); + + if (mSelectedIndex + 1 < count) { + View nextIcon = mIconsLayout.getChildAt(mSelectedIndex + 1); + left = (int) (mSelectionOffset * nextIcon.getLeft() + + (1.0f - mSelectionOffset) * left); + right = (int) (mSelectionOffset * nextIcon.getRight() + + (1.0f - mSelectionOffset) * right); + } + + mIndicatorPaint.setColor(mIndicatorColor); + canvas.drawRect(left, top, right, bottom, mIndicatorPaint); + } + + public void notifyDataSetChanged() { + mIconsLayout.removeAllViews(); + IconPagerAdapter iconAdapter = (IconPagerAdapter) mViewPager.getAdapter(); + int count = iconAdapter.getCount(); + for (int i = 0; i < count; i++) { + ImageView view = new ImageView(getContext(), null, R.attr.actionButtonStyle); + view.setImageResource(iconAdapter.getIconResId(i)); + view.setTag(i); + view.setOnClickListener(this); + mIconsLayout.addView(view, new LayoutParams(WRAP_CONTENT, MATCH_PARENT)); + } + if (mSelectedIndex > count) { + mSelectedIndex = count - 1; + } + setCurrentItem(mSelectedIndex); + requestLayout(); + } + + public void setViewPager(ViewPager view, int initialPosition) { + setViewPager(view); + setCurrentItem(initialPosition); + } + + public void setCurrentItem(int item) { + if (mViewPager == null) { + throw new IllegalStateException("ViewPager has not been bound."); + } + mSelectedIndex = item; + mViewPager.setCurrentItem(item); + + int tabCount = mIconsLayout.getChildCount(); + for (int i = 0; i < tabCount; i++) { + View child = mIconsLayout.getChildAt(i); + boolean isSelected = (i == item); + child.setSelected(isSelected); + if (isSelected) { + animateToIcon(item); + } + } + } + + public void setOnPageChangeListener(OnPageChangeListener listener) { + mListener = listener; + } + + public void setOnClickListener(OnClickListener clickListener) { + mClickListener = clickListener; + } + + @Override + public void onClick(View v) { + if (mViewPager != null && v instanceof ImageView) { + mViewPager.setCurrentItem((Integer) v.getTag()); + } + if (mClickListener != null) { + mClickListener.onClick(v); + } + } + + public interface IconPagerAdapter { + int getIconResId(int index); + int getCount(); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/InlineTranslationView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/InlineTranslationView.java new file mode 100644 index 0000000000..c68fe7e827 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/InlineTranslationView.java @@ -0,0 +1,146 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Typeface; +import android.support.annotation.StyleRes; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.common.QuranAyahInfo; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.List; + +public class InlineTranslationView extends ScrollView { + private Context context; + private Resources resources; + private int leftRightMargin; + private int topBottomMargin; + @StyleRes private int textStyle; + private int fontSize; + private int footerSpacerHeight; + + private String[] translations; + private List ayat; + + private LinearLayout linearLayout; + + public InlineTranslationView(Context context) { + this(context, null); + } + + public InlineTranslationView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public InlineTranslationView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context); + } + + private void init(Context context) { + this.context = context; + + setFillViewport(true); + linearLayout = new LinearLayout(context); + linearLayout.setOrientation(LinearLayout.VERTICAL); + addView(linearLayout, ScrollView.LayoutParams.MATCH_PARENT, + ScrollView.LayoutParams.WRAP_CONTENT); + + resources = getResources(); + leftRightMargin = resources.getDimensionPixelSize(R.dimen.translation_left_right_margin); + topBottomMargin = resources.getDimensionPixelSize(R.dimen.translation_top_bottom_margin); + footerSpacerHeight = resources.getDimensionPixelSize(R.dimen.translation_footer_spacer); + initResources(); + } + + private void initResources() { + QuranSettings settings = QuranSettings.getInstance(context); + fontSize = settings.getTranslationTextSize(); + textStyle = R.style.TranslationText; + } + + public void refresh() { + if (ayat != null && translations != null) { + initResources(); + setAyahs(translations, ayat); + } + } + + public void setAyahs(String[] translations, List ayat) { + linearLayout.removeAllViews(); + if (ayat.size() > 0 && ayat.get(0).texts.size() > 0) { + this.ayat = ayat; + this.translations = translations; + + for (int i = 0, ayatSize = ayat.size(); i < ayatSize; i++) { + addTextForAyah(translations, ayat.get(i)); + } + addFooterSpacer(); + } + } + + private void addFooterSpacer() { + final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, footerSpacerHeight); + final View view = new View(context); + linearLayout.addView(view, params); + } + + private void addTextForAyah(String[] translations, QuranAyahInfo ayah) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.setMargins(leftRightMargin, topBottomMargin, leftRightMargin, topBottomMargin); + + final int suraNumber = ayah.sura; + final int ayahNumber = ayah.ayah; + TextView ayahHeader = new TextView(context); + ayahHeader.setTextColor(Color.WHITE); + ayahHeader.setTextSize(fontSize); + ayahHeader.setTypeface(null, Typeface.BOLD); + ayahHeader.setText(resources.getString(R.string.sura_ayah, suraNumber, ayahNumber)); + linearLayout.addView(ayahHeader, params); + + TextView ayahView = new TextView(context); + ayahView.setTextAppearance(context, textStyle); + ayahView.setTextColor(Color.WHITE); + ayahView.setTextSize(fontSize); + + // translation + boolean showHeader = translations.length > 1; + SpannableStringBuilder builder = new SpannableStringBuilder(); + for (int i = 0; i < translations.length; i++) { + String translationText = ayah.texts.get(i); + if (!TextUtils.isEmpty(translationText)) { + if (showHeader) { + if (i > 0) { + builder.append("\n\n"); + } + int start = builder.length(); + builder.append(translations[i]); + builder.setSpan(new StyleSpan(Typeface.BOLD), + start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.append("\n\n"); + } + builder.append(translationText); + } + } + ayahView.append(builder); + + params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.setMargins(leftRightMargin, topBottomMargin, leftRightMargin, topBottomMargin); + ayahView.setTextIsSelectable(true); + linearLayout.addView(ayahView, params); + } + +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/JuzView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/JuzView.java new file mode 100644 index 0000000000..02e8930b6b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/JuzView.java @@ -0,0 +1,117 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.support.v4.content.ContextCompat; +import android.text.TextPaint; +import android.text.TextUtils; + +import com.quran.labs.androidquran.R; + +public class JuzView extends Drawable { + public static final int TYPE_JUZ = 1; + public static final int TYPE_QUARTER = 2; + public static final int TYPE_HALF = 3; + public static final int TYPE_THREE_QUARTERS = 4; + + private int mRadius; + private int mCircleY; + private int mPercentage; + private float mTextOffset; + private String mOverlayText; + + private RectF mCircleRect; + private Paint mCirclePaint; + private TextPaint mOverlayTextPaint; + private Paint mCircleBackgroundPaint; + + public JuzView(Context context, int type, String overlayText) { + final Resources resources = context.getResources(); + final int circleColor = ContextCompat.getColor(context, R.color.accent_color); + final int circleBackground = ContextCompat.getColor(context, R.color.accent_color_dark); + + mCirclePaint = new Paint(); + mCirclePaint.setStyle(Paint.Style.FILL); + mCirclePaint.setColor(circleColor); + mCirclePaint.setAntiAlias(true); + + mCircleBackgroundPaint = new Paint(); + mCircleBackgroundPaint.setStyle(Paint.Style.FILL); + mCircleBackgroundPaint.setColor(circleBackground); + mCircleBackgroundPaint.setAntiAlias(true); + + mOverlayText = overlayText; + if (!TextUtils.isEmpty(mOverlayText)) { + final int textColor = ContextCompat.getColor(context, R.color.header_background); + final int textSize = + resources.getDimensionPixelSize(R.dimen.juz_overlay_text_size); + mOverlayTextPaint = new TextPaint(); + mOverlayTextPaint.setAntiAlias(true); + mOverlayTextPaint.setColor(textColor); + mOverlayTextPaint.setTextSize(textSize); + mOverlayTextPaint.setTextAlign(Paint.Align.CENTER); + + final float textHeight = + mOverlayTextPaint.descent() - mOverlayTextPaint.ascent(); + mTextOffset = (textHeight / 2) - mOverlayTextPaint.descent(); + } + + final int percentage; + switch (type) { + case TYPE_JUZ: + percentage = 100; + break; + case TYPE_THREE_QUARTERS: + percentage = 75; + break; + case TYPE_HALF: + percentage = 50; + break; + case TYPE_QUARTER: + percentage = 25; + break; + default: + percentage = 0; + } + mPercentage = percentage; + } + + @Override + public void setBounds(int left, int top, int right, int bottom) { + super.setBounds(left, top, right, bottom); + mRadius = (right - left) / 2; + final int yOffset = ((bottom - top) - (2 * mRadius)) / 2; + mCircleY = mRadius + yOffset; + mCircleRect = new RectF(left, top + yOffset, + right, top + yOffset + 2 * mRadius); + } + + @Override + public void draw(Canvas canvas) { + canvas.drawCircle(mRadius, mCircleY, mRadius, mCircleBackgroundPaint); + canvas.drawArc(mCircleRect, -90, + (int) (3.6 * mPercentage), true, mCirclePaint); + if (mOverlayTextPaint != null) { + canvas.drawText(mOverlayText, mCircleRect.centerX(), + mCircleRect.centerY() + mTextOffset, mOverlayTextPaint); + } + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter cf) { + } + + @Override + public int getOpacity() { + return 0; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/LeftToRightLinearLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/LeftToRightLinearLayout.java new file mode 100644 index 0000000000..28b3b4e23d --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/LeftToRightLinearLayout.java @@ -0,0 +1,34 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +public class LeftToRightLinearLayout extends LinearLayout { + public LeftToRightLinearLayout(Context context) { + this(context, null); + } + + public LeftToRightLinearLayout(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public LeftToRightLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + setLayoutDirection(LAYOUT_DIRECTION_LTR); + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public LeftToRightLinearLayout(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + setLayoutDirection(LAYOUT_DIRECTION_LTR); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/ObservableScrollView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/ObservableScrollView.java new file mode 100644 index 0000000000..99696a813e --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/ObservableScrollView.java @@ -0,0 +1,38 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ScrollView; + +public class ObservableScrollView extends ScrollView { + + private OnScrollListener mListener; + + public interface OnScrollListener { + void onScrollChanged(ObservableScrollView scrollView, int x, int y, int oldx, int oldy); + } + + public ObservableScrollView(Context context) { + super(context); + } + + public ObservableScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ObservableScrollView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public void setOnScrollListener(OnScrollListener listener) { + mListener = listener; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (mListener != null) { + mListener.onScrollChanged(this, l, t, oldl, oldt); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranHeaderView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranHeaderView.java new file mode 100644 index 0000000000..5dc235f4d1 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranHeaderView.java @@ -0,0 +1,74 @@ +package com.quran.labs.androidquran.widgets; + +import com.quran.labs.androidquran.util.QuranSettings; +import com.quran.labs.androidquran.util.QuranUtils; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +public class QuranHeaderView extends ViewGroup { + + private View mTitle; + private View mPageNumber; + private boolean mIsRtl; + + public QuranHeaderView(Context context) { + this(context, null); + } + + public QuranHeaderView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public QuranHeaderView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + mIsRtl = QuranSettings.getInstance(context).isArabicNames() || QuranUtils.isRtl(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + mTitle = getChildAt(0); + mPageNumber = getChildAt(1); + + measureChildWithMargins(mPageNumber, widthMeasureSpec, 0, heightMeasureSpec, 0); + measureChildWithMargins(mTitle, widthMeasureSpec, + mPageNumber.getMeasuredWidth(), heightMeasureSpec, 0); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + View left = mTitle; + View right = mPageNumber; + if (mIsRtl) { + left = mPageNumber; + right = mTitle; + } + + int top = ((b - t) - mTitle.getMeasuredHeight()) / 2; + left.layout(getPaddingLeft(), + top, getPaddingLeft() + left.getMeasuredWidth(), top + left.getMeasuredHeight()); + top = ((b - t) - mPageNumber.getMeasuredHeight()) / 2; + right.layout(r - (right.getMeasuredWidth() + getPaddingRight()), + top, r - getPaddingRight(), top + right.getMeasuredHeight()); + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new MarginLayoutParams(getContext(), attrs); + } + + @Override + protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + return new MarginLayoutParams(p); + } + + @Override + protected ViewGroup.LayoutParams generateDefaultLayoutParams() { + return new MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranImagePageLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranImagePageLayout.java new file mode 100644 index 0000000000..d756c3f90f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranImagePageLayout.java @@ -0,0 +1,74 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +import com.quran.labs.androidquran.ui.helpers.AyahSelectedListener; +import com.quran.labs.androidquran.ui.util.PageController; +import com.quran.labs.androidquran.util.QuranSettings; + +public class QuranImagePageLayout extends QuranPageLayout { + private HighlightingImageView imageView; + + public QuranImagePageLayout(Context context) { + super(context); + } + + @Override + protected View generateContentView(Context context, boolean isLandscape) { + imageView = new HighlightingImageView(context); + imageView.setAdjustViewBounds(true); + imageView.setIsScrollable(isLandscape && shouldWrapWithScrollView()); + return imageView; + } + + @Override + public void updateView(@NonNull QuranSettings quranSettings) { + super.updateView(quranSettings); + imageView.setNightMode(quranSettings.isNightMode(), quranSettings.getNightModeTextBrightness()); + } + + public HighlightingImageView getImageView() { + return imageView; + } + + @Override + public void setPageController(PageController controller, int pageNumber) { + super.setPageController(controller, pageNumber); + final GestureDetector gestureDetector = new GestureDetector(context, + new PageGestureDetector()); + OnTouchListener gestureListener = (v, event) -> gestureDetector.onTouchEvent(event); + imageView.setOnTouchListener(gestureListener); + imageView.setClickable(true); + imageView.setLongClickable(true); + } + + private class PageGestureDetector extends GestureDetector.SimpleOnGestureListener { + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent event) { + return pageController.handleTouchEvent(event, + AyahSelectedListener.EventType.SINGLE_TAP, pageNumber); + } + + @Override + public boolean onDoubleTap(MotionEvent event) { + return pageController.handleTouchEvent(event, + AyahSelectedListener.EventType.DOUBLE_TAP, pageNumber); + } + + @Override + public void onLongPress(MotionEvent event) { + pageController.handleTouchEvent(event, + AyahSelectedListener.EventType.LONG_PRESS, pageNumber); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranPageLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranPageLayout.java new file mode 100644 index 0000000000..182e675429 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranPageLayout.java @@ -0,0 +1,269 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.PaintDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RectShape; +import android.os.Build; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Px; +import android.support.v4.content.ContextCompat; +import android.support.v4.view.ViewCompat; +import android.view.Display; +import android.view.View; +import android.view.WindowManager; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.data.Constants; +import com.quran.labs.androidquran.ui.helpers.QuranDisplayHelper; +import com.quran.labs.androidquran.ui.util.PageController; +import com.quran.labs.androidquran.util.QuranSettings; + +public abstract class QuranPageLayout extends QuranPageWrapperLayout + implements ObservableScrollView.OnScrollListener { + + @IntDef( { BorderMode.HIDDEN, BorderMode.LIGHT, BorderMode.DARK, BorderMode.LINE } ) + @interface BorderMode { + int HIDDEN = 0; + int LIGHT = 1; + int DARK = 2; + int LINE = 3; + } + + private static PaintDrawable leftGradient; + private static PaintDrawable rightGradient; + private static int gradientForNumberOfPages; + private static boolean areGradientsLandscape; + private static BitmapDrawable leftPageBorder; + private static BitmapDrawable rightPageBorder; + private static BitmapDrawable leftPageBorderNight; + private static BitmapDrawable rightPageBorderNight; + + private static int lineColor; + private static ShapeDrawable lineDrawable; + + protected Context context; + protected PageController pageController; + protected int pageNumber; + protected boolean shouldHideLine; + protected boolean isFullWidth; + + private ObservableScrollView scrollView; + private @BorderMode int leftBorder; + private @BorderMode int rightBorder; + private View innerView; + private int viewPaddingSmall; + private int viewPaddingLarge; + + public QuranPageLayout(Context context) { + super(context); + this.context = context; + ViewCompat.setLayoutDirection(this, ViewCompat.LAYOUT_DIRECTION_LTR); + Resources resources = context.getResources(); + final boolean isLandscape = + resources.getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + innerView = generateContentView(context, isLandscape); + viewPaddingSmall = resources.getDimensionPixelSize(R.dimen.page_margin_small); + viewPaddingLarge = resources.getDimensionPixelSize(R.dimen.page_margin_large); + + LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + if (isLandscape && shouldWrapWithScrollView()) { + scrollView = new ObservableScrollView(context); + scrollView.setFillViewport(true); + addView(scrollView, lp); + scrollView.addView(innerView, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + scrollView.setOnScrollListener(this); + } else { + addView(innerView, lp); + } + + if (areGradientsLandscape != isLandscape) { + leftGradient = null; + rightGradient = null; + areGradientsLandscape = isLandscape; + } + + if (lineDrawable == null) { + lineDrawable = new ShapeDrawable(new RectShape()); + lineDrawable.setIntrinsicWidth(1); + lineDrawable.setIntrinsicHeight(1); + + // these bitmaps are 11x1, so fairly small to keep both day and night versions around + leftPageBorder = new BitmapDrawable(resources, + BitmapFactory.decodeResource(resources, R.drawable.border_left)); + leftPageBorderNight = new BitmapDrawable(resources, + BitmapFactory.decodeResource(resources, R.drawable.night_left_border)); + rightPageBorder = new BitmapDrawable(resources, + BitmapFactory.decodeResource(resources, R.drawable.border_right)); + rightPageBorderNight = new BitmapDrawable(resources, + BitmapFactory.decodeResource(resources, R.drawable.night_right_border)); + } + + updateGradients(); + setWillNotDraw(false); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + View view = resolveView(); + if (view != null) { + int width = MeasureSpec.getSize(widthMeasureSpec); + int height = MeasureSpec.getSize(heightMeasureSpec); + if (!isFullWidth) { + int leftLineWidth = leftBorder == BorderMode.LINE ? 1 : leftPageBorder.getIntrinsicWidth(); + int rightLineWidth = rightBorder == BorderMode.HIDDEN ? + 0 : rightPageBorder.getIntrinsicWidth(); + int headerFooterHeight = 0; + width = width - (leftLineWidth + rightLineWidth + viewPaddingSmall + viewPaddingLarge); + height = height - 2 * headerFooterHeight; + } + view.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + View view = resolveView(); + if (view != null) { + int width = getMeasuredWidth(); + int height = getMeasuredHeight(); + @Px int leftLineWidth = leftBorder == BorderMode.LINE ? + 1 : leftPageBorder.getIntrinsicWidth(); + @Px int rightLineWidth = rightBorder == BorderMode.HIDDEN ? + 0 : rightPageBorder.getIntrinsicWidth(); + int headerFooterHeight = 0; + view.layout(leftLineWidth, headerFooterHeight, + width - rightLineWidth, height - headerFooterHeight); + super.onLayout(changed, l, t, r, b); + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + int width = getWidth(); + if (width > 0) { + int height = getHeight(); + if (leftBorder != BorderMode.LINE || !shouldHideLine) { + Drawable left = leftBorder == BorderMode.LINE ? lineDrawable : + leftBorder == BorderMode.LIGHT ? leftPageBorder : leftPageBorderNight; + left.setBounds(0, 0, left.getIntrinsicWidth(), height); + left.draw(canvas); + } + + if (rightBorder != BorderMode.HIDDEN) { + Drawable right = rightBorder == BorderMode.LIGHT ? rightPageBorder : rightPageBorderNight; + right.setBounds(width - right.getIntrinsicWidth(), 0, width, height); + right.draw(canvas); + } + } + } + + protected abstract View generateContentView(Context context, boolean isLandscape); + + protected boolean shouldWrapWithScrollView() { + return true; + } + + private View resolveView() { + return scrollView != null ? scrollView : innerView; + } + + public void setPageController(PageController controller, int pageNumber) { + this.pageNumber = pageNumber; + this.pageController = controller; + } + + protected int getPagesVisible() { + return 1; + } + + private void updateGradients() { + int pagesVisible = getPagesVisible(); + if (rightGradient == null || gradientForNumberOfPages != pagesVisible) { + final WindowManager mgr = + (WindowManager) context.getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + Display display = mgr.getDefaultDisplay(); + int width = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ? + QuranDisplayHelper.getWidthKitKat(display) : display.getWidth(); + width = width / pagesVisible; + leftGradient = QuranDisplayHelper.getPaintDrawable(width, 0); + rightGradient = QuranDisplayHelper.getPaintDrawable(0, width); + gradientForNumberOfPages = pagesVisible; + } + } + + @Override + public void updateView(@NonNull QuranSettings quranSettings) { + super.updateView(quranSettings); + boolean nightMode = quranSettings.isNightMode(); + int lineColor = Color.BLACK; + final int nightModeTextBrightness = nightMode ? + quranSettings.getNightModeTextBrightness() : Constants.DEFAULT_NIGHT_MODE_TEXT_BRIGHTNESS; + if (nightMode) { + lineColor = Color.argb(nightModeTextBrightness, 255, 255, 255); + } + + if (pageNumber % 2 == 0) { + leftBorder = nightMode ? BorderMode.DARK : BorderMode.LIGHT; + rightBorder = BorderMode.HIDDEN; + } else { + rightBorder = nightMode ? BorderMode.DARK : BorderMode.LIGHT; + if (QuranPageLayout.lineColor != lineColor) { + QuranPageLayout.lineColor = lineColor; + lineDrawable.getPaint().setColor(lineColor); + } + leftBorder = BorderMode.LINE; + } + + updateBackground(nightMode, quranSettings); + } + + protected void updateBackground(boolean nightMode, QuranSettings quranSettings) { + if (nightMode) { + setBackgroundColor(Color.BLACK); + } else if (quranSettings.useNewBackground()) { + setBackgroundDrawable((pageNumber % 2 == 0 ? leftGradient : rightGradient)); + } else { + setBackgroundColor(ContextCompat.getColor(context, R.color.page_background)); + } + } + + @Override + void handleRetryClicked() { + if (pageController != null) { + pageController.handleRetryClicked(); + } + } + + public int getCurrentScrollY() { + return scrollView == null ? 0 : scrollView.getScrollY(); + } + + public boolean canScroll() { + return scrollView != null; + } + + public void smoothScrollLayoutTo(int y) { + scrollView.smoothScrollTo(scrollView.getScrollX(), y); + } + + @Override + public void onScrollChanged(ObservableScrollView scrollView, + int x, int y, int oldx, int oldy) { + if (pageController != null) { + pageController.onScrollChanged(x, y, oldx, oldy); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranPageWrapperLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranPageWrapperLayout.java new file mode 100644 index 0000000000..ea0182cb9f --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranPageWrapperLayout.java @@ -0,0 +1,88 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.util.QuranSettings; + +public abstract class QuranPageWrapperLayout extends ViewGroup { + + private View errorLayout; + private TextView errorText; + private boolean isNightMode; + + public QuranPageWrapperLayout(Context context) { + super(context); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (errorLayout != null) { + int width = MeasureSpec.getSize(widthMeasureSpec); + int height = MeasureSpec.getSize(heightMeasureSpec); + errorLayout.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST)); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + if (errorLayout != null) { + int errorWidth = errorLayout.getMeasuredWidth(); + int errorHeight = errorLayout.getMeasuredHeight(); + int x = (getMeasuredWidth() - errorWidth) / 2; + int y = (getMeasuredHeight() - errorHeight) / 2; + errorLayout.layout(x, y, x + errorWidth, y + errorHeight); + } + } + + public void updateView(@NonNull QuranSettings quranSettings) { + isNightMode = quranSettings.isNightMode(); + if (errorText != null) { + updateErrorTextColor(); + } + } + + public void showError(@StringRes int errorRes) { + if (errorLayout == null) { + inflateErrorLayout(); + } + errorLayout.setVisibility(VISIBLE); + errorText.setText(errorRes); + } + + public void hideError() { + if (errorLayout != null) { + errorLayout.setVisibility(GONE); + } + } + + private void inflateErrorLayout() { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + errorLayout = inflater.inflate(R.layout.page_load_error, this, false); + LayoutParams lp = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + addView(errorLayout, lp); + errorText = (TextView) errorLayout.findViewById(R.id.reason_text); + final Button button = (Button) errorLayout.findViewById(R.id.retry_button); + updateErrorTextColor(); + button.setOnClickListener(v -> { + errorLayout.setVisibility(GONE); + handleRetryClicked(); + }); + } + + abstract void handleRetryClicked(); + + private void updateErrorTextColor() { + errorText.setTextColor(isNightMode ? Color.WHITE : Color.BLACK); + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranSpinner.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranSpinner.java new file mode 100644 index 0000000000..427434cc04 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranSpinner.java @@ -0,0 +1,91 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.support.v7.widget.AppCompatSpinner; +import android.util.AttributeSet; +import android.view.View; +import android.widget.SpinnerAdapter; + +/** + * An {@link AppCompatSpinner} that uses the last items in an adapter and a multiplier to + * determine the width of the Spinner and its dropdown. + * + * AppCompatSpinner uses the measurement of the first 15 items to determine the width. + */ +public class QuranSpinner extends AppCompatSpinner { + private static final int MAX_ITEMS_MEASURED = 15; + private static final float WIDTH_MULTIPLIER = 1.1f; + + private SpinnerAdapter adapter; + + public QuranSpinner(Context context) { + super(context); + } + + public QuranSpinner(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public QuranSpinner(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setAdapter(SpinnerAdapter adapter) { + super.setAdapter(adapter); + this.adapter = adapter; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) { + int calculatedWidth = calculateWidth(); + int measuredWidth = getMeasuredWidth(); + if (calculatedWidth > measuredWidth) { + setMeasuredDimension( + Math.min(calculatedWidth, MeasureSpec.getSize(widthMeasureSpec)), + getMeasuredHeight()); + setDropDownWidth(calculatedWidth); + } else { + setDropDownWidth(measuredWidth); + } + } + } + + private int calculateWidth() { + if (adapter == null) { + return 0; + } + + int width = 0; + View itemView = null; + int itemType = 0; + final int widthMeasureSpec = + MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.UNSPECIFIED); + final int heightMeasureSpec = + MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.UNSPECIFIED); + + // Make sure the number of items we'll measure is capped. If it's a huge data set + // with wildly varying sizes, oh well. + final int end = adapter.getCount(); + int start = Math.max(end - MAX_ITEMS_MEASURED, 0); + for (int i = start; i < end; i++) { + final int positionType = adapter.getItemViewType(i); + if (positionType != itemType) { + itemType = positionType; + itemView = null; + } + itemView = adapter.getView(i, itemView, this); + if (itemView.getLayoutParams() == null) { + itemView.setLayoutParams(new LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT)); + } + itemView.measure(widthMeasureSpec, heightMeasureSpec); + width = Math.max(width, itemView.getMeasuredWidth()); + } + width *= WIDTH_MULTIPLIER; // add some extra spacing + return width; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranTabletImagePageLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranTabletImagePageLayout.java new file mode 100644 index 0000000000..8598540996 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranTabletImagePageLayout.java @@ -0,0 +1,15 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; + +public class QuranTabletImagePageLayout extends QuranImagePageLayout { + + public QuranTabletImagePageLayout(Context context) { + super(context); + } + + @Override + protected boolean shouldWrapWithScrollView() { + return false; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranTranslationPageLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranTranslationPageLayout.java new file mode 100644 index 0000000000..39ebe143b6 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranTranslationPageLayout.java @@ -0,0 +1,49 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.NonNull; +import android.view.View; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.ui.translation.TranslationView; +import com.quran.labs.androidquran.util.QuranSettings; + +public class QuranTranslationPageLayout extends QuranPageLayout { + private TranslationView translationView; + + public QuranTranslationPageLayout(Context context) { + super(context); + isFullWidth = true; + } + + @Override + protected View generateContentView(Context context, boolean isLandscape) { + translationView = new TranslationView(context); + return translationView; + } + + @Override + public void updateView(@NonNull QuranSettings quranSettings) { + super.updateView(quranSettings); + translationView.refresh(quranSettings); + } + + @Override + protected void updateBackground(boolean nightMode, QuranSettings quranSettings) { + if (nightMode) { + setBackgroundResource(R.color.translation_background_color_night); + } else { + setBackgroundColor(Color.WHITE); + } + } + + @Override + protected boolean shouldWrapWithScrollView() { + return false; + } + + public TranslationView getTranslationView() { + return translationView; + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/QuranViewPager.java b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranViewPager.java new file mode 100644 index 0000000000..24afc7ece2 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/QuranViewPager.java @@ -0,0 +1,26 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; + +public class QuranViewPager extends ViewPager { + + public QuranViewPager(Context context) { + super(context); + } + + public QuranViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + try { + return super.onTouchEvent(ev); + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/RepeatButton.java b/app/src/main/java/com/quran/labs/androidquran/widgets/RepeatButton.java new file mode 100644 index 0000000000..9bd8007123 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/RepeatButton.java @@ -0,0 +1,81 @@ +package com.quran.labs.androidquran.widgets; + +import com.quran.labs.androidquran.R; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.widget.ImageView; + +public class RepeatButton extends ImageView { + @NonNull private String mText; + @NonNull private TextPaint mPaint; + private boolean mCanDraw; + private int mViewWidth; + private int mViewHeight; + private int mTextXPosition; + private int mTextYPosition; + private int mTextYPadding; + + public RepeatButton(Context context) { + this(context, null); + } + + public RepeatButton(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RepeatButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + mPaint.setColor(Color.WHITE); + final Resources resources = context.getResources(); + mPaint.setTextSize(resources.getDimensionPixelSize(R.dimen.repeat_superscript_text_size)); + mTextYPadding = resources.getDimensionPixelSize(R.dimen.repeat_text_y_padding); + mText = ""; + } + + public void setText(@NonNull String text) { + mText = text; + updateCoordinates(); + invalidate(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + mViewWidth = getMeasuredWidth(); + mViewHeight = getMeasuredHeight(); + updateCoordinates(); + } + + private void updateCoordinates() { + mCanDraw = false; + final Drawable drawable = getDrawable(); + if (drawable != null) { + final Rect bounds = drawable.getBounds(); + if (bounds != null && bounds.width() > 0) { + mTextXPosition = mViewWidth - (mViewWidth - bounds.width()) / 2; + mTextYPosition = mTextYPadding + (mViewHeight - bounds.height()) / 2; + mCanDraw = true; + } + } + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + final int length = mText.length(); + if (mCanDraw && length > 0) { + canvas.drawText(mText, 0, length, mTextXPosition, mTextYPosition, mPaint); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingTabLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingTabLayout.java new file mode 100644 index 0000000000..734ae1017b --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingTabLayout.java @@ -0,0 +1,362 @@ +package com.quran.labs.androidquran.widgets; + +/* + * Copyright (C) 2013 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. + */ + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Typeface; +import android.support.v4.content.ContextCompat; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; + +import java.util.Locale; + +/** + * To be used with ViewPager to provide a tab indicator component which give constant feedback as to + * the user's scroll progress. + *

+ * To use the component, simply add it to your view hierarchy. Then in your + * {@link android.app.Activity} or {@link android.support.v4.app.Fragment} call + * {@link #setViewPager(ViewPager)} providing it the ViewPager this layout is being used for. + *

+ * The colors can be customized in two ways. The first and simplest is to provide an array of colors + * via {@link #setSelectedIndicatorColors(int...)}. The alternative is via the {@link TabColorizer} + * interface which provides you complete control over which color is used for any individual + * position. + * + *

+ * The views used as tabs can be customized by calling {@link #setCustomTabView(int, int)}, + * providing the layout ID of your custom layout. + * + * Modified for Quran Android to evenly distribute tabs, while allowing + * horizontal scrolling when it makes sense. + */ +public class SlidingTabLayout extends HorizontalScrollView { + + /** + * Allows complete control over the colors drawn in the tab layout. Set with + * {@link #setCustomTabColorizer(TabColorizer)}. + */ + interface TabColorizer { + + /** + * @return return the color of the indicator used when {@code position} is selected. + */ + int getIndicatorColor(int position); + } + + private static final int TITLE_OFFSET_DIPS = 24; + private static final int TAB_VIEW_PADDING_DIPS = 16; + private static final int TAB_VIEW_TEXT_SIZE_SP = 12; + + private int mTitleOffset; + private int mTabPadding; + + private int mTabViewLayoutId; + private int mTabViewTextViewId; + private int mSelectedTabColor; + private int mUnselectedTabColor; + + private ViewPager mViewPager; + private ViewPager.OnPageChangeListener mViewPagerPageChangeListener; + + private final SlidingTabStrip mTabStrip; + + public SlidingTabLayout(Context context) { + this(context, null); + } + + public SlidingTabLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SlidingTabLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + // Disable the Scroll Bar + setHorizontalScrollBarEnabled(false); + // Make sure that the Tab Strips fills this View + setFillViewport(true); + + final Resources resources = getResources(); + final float density = resources.getDisplayMetrics().density; + mTitleOffset = (int) (TITLE_OFFSET_DIPS * density); + mTabPadding = (int) (TAB_VIEW_PADDING_DIPS * density); + + mSelectedTabColor = ContextCompat.getColor(context, R.color.color_control_activated); + mUnselectedTabColor = ContextCompat.getColor(context, R.color.color_control_normal); + + mTabStrip = new SlidingTabStrip(context); + mTabStrip.setSelectedIndicatorColors(ContextCompat.getColor(context, R.color.indicator_color)); + addView(mTabStrip, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + + /** + * Set the custom {@link TabColorizer} to be used. + * + * If you only require simple customization then you can use + * {@link #setSelectedIndicatorColors(int...)} to achieve + * similar effects. + */ + public void setCustomTabColorizer(TabColorizer tabColorizer) { + mTabStrip.setCustomTabColorizer(tabColorizer); + } + + /** + * Sets the colors to be used for indicating the selected tab. These colors are treated as a + * circular array. Providing one color will mean that all tabs are indicated with the same color. + */ + public void setSelectedIndicatorColors(int... colors) { + mTabStrip.setSelectedIndicatorColors(colors); + } + + /** + * Set the {@link ViewPager.OnPageChangeListener}. When using {@link SlidingTabLayout} you are + * required to set any {@link ViewPager.OnPageChangeListener} through this method. This is so + * that the layout can update it's scroll position correctly. + * + * @see ViewPager#setOnPageChangeListener(ViewPager.OnPageChangeListener) + */ + public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) { + mViewPagerPageChangeListener = listener; + } + + /** + * Set the custom layout to be inflated for the tab views. + * + * @param layoutResId Layout id to be inflated + * @param textViewId id of the {@link TextView} in the inflated view + */ + public void setCustomTabView(int layoutResId, int textViewId) { + mTabViewLayoutId = layoutResId; + mTabViewTextViewId = textViewId; + } + + /** + * Sets the associated view pager. Note that the assumption here is that the pager content + * (number of tabs and tab titles) does not change after this call has been made. + */ + public void setViewPager(ViewPager viewPager) { + mTabStrip.removeAllViews(); + + mViewPager = viewPager; + if (viewPager != null) { + viewPager.addOnPageChangeListener(new InternalViewPagerListener()); + populateTabStrip(); + updateTabsTextColor(); + } + } + + /** + * Create a default view to be used for tabs. This is called if a custom tab view is not set via + * {@link #setCustomTabView(int, int)}. + */ + protected TextView createDefaultTabView(Context context) { + TextView textView = new TextView(context); + textView.setGravity(Gravity.CENTER); + textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, TAB_VIEW_TEXT_SIZE_SP); + textView.setTypeface(Typeface.DEFAULT_BOLD); + textView.setSingleLine(); + textView.setTextColor(mUnselectedTabColor); + + // If we're running on Honeycomb or newer, then we can use the Theme's + // selectableItemBackground to ensure that the View has a pressed state + TypedValue outValue = new TypedValue(); + getContext().getTheme().resolveAttribute(android.R.attr.selectableItemBackground, + outValue, true); + textView.setBackgroundResource(outValue.resourceId); + textView.setAllCaps(true); + + int padding = mTabPadding; + textView.setPadding(padding, padding, padding, padding); + + return textView; + } + + private void populateTabStrip() { + final PagerAdapter adapter = mViewPager.getAdapter(); + final View.OnClickListener tabClickListener = new TabClickListener(); + + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + final float fontSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, TAB_VIEW_TEXT_SIZE_SP, metrics); + final TextPaint paint = new TextPaint(); + paint.setTextSize(fontSize); + paint.setTypeface(Typeface.DEFAULT_BOLD); + + int targetWidth = 0; + final int tabs = adapter.getCount(); + for (int i = 0; i < tabs; i++) { + String str = adapter.getPageTitle(i).toString(); + str = str.toUpperCase(Locale.getDefault()); + + int width = (int) paint.measureText(str); + width = width + 2 * mTabPadding; + if (width > targetWidth) { + targetWidth = width; + } + } + + if (targetWidth * tabs < metrics.widthPixels) { + targetWidth = metrics.widthPixels / tabs; + } + + for (int i = 0; i < adapter.getCount(); i++) { + View tabView = null; + TextView tabTitleView = null; + + if (mTabViewLayoutId != 0) { + // If there is a custom tab view layout id set, try and inflate it + tabView = LayoutInflater.from(getContext()) + .inflate(mTabViewLayoutId, mTabStrip, false); + tabTitleView = (TextView) tabView.findViewById(mTabViewTextViewId); + } + + if (tabView == null) { + tabView = createDefaultTabView(getContext()); + } + + if (tabTitleView == null && TextView.class.isInstance(tabView)) { + tabTitleView = (TextView) tabView; + } + + if (tabTitleView != null) { + tabTitleView.setText(adapter.getPageTitle(i)); + } + tabView.setOnClickListener(tabClickListener); + + final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + targetWidth, ViewGroup.LayoutParams.WRAP_CONTENT); + mTabStrip.addView(tabView, params); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (mViewPager != null) { + scrollToTab(mViewPager.getCurrentItem(), 0); + } + } + + private void scrollToTab(int tabIndex, int positionOffset) { + final int tabStripChildCount = mTabStrip.getChildCount(); + if (tabStripChildCount == 0 || tabIndex < 0 || tabIndex >= tabStripChildCount) { + return; + } + + View selectedChild = mTabStrip.getChildAt(tabIndex); + if (selectedChild != null) { + int targetScrollX = selectedChild.getLeft() + positionOffset; + + if (tabIndex > 0 || positionOffset > 0) { + // If we're not at the first child and are mid-scroll, make sure we obey the offset + targetScrollX -= mTitleOffset; + } + + scrollTo(targetScrollX, 0); + } + } + + private class InternalViewPagerListener implements ViewPager.OnPageChangeListener { + private int mScrollState; + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + mTabStrip.onViewPagerPageChanged(position, positionOffset); + + View selectedTitle = mTabStrip.getChildAt(position); + int extraOffset = (selectedTitle != null) + ? (int) (positionOffset * selectedTitle.getWidth()) + : 0; + scrollToTab(position, extraOffset); + + if (mViewPagerPageChangeListener != null) { + mViewPagerPageChangeListener.onPageScrolled(position, positionOffset, + positionOffsetPixels); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + mScrollState = state; + + if (mViewPagerPageChangeListener != null) { + mViewPagerPageChangeListener.onPageScrollStateChanged(state); + } + } + + @Override + public void onPageSelected(int position) { + if (mScrollState == ViewPager.SCROLL_STATE_IDLE) { + mTabStrip.onViewPagerPageChanged(position, 0f); + scrollToTab(position, 0); + } + + updateTabsTextColor(); + + if (mViewPagerPageChangeListener != null) { + mViewPagerPageChangeListener.onPageSelected(position); + } + } + + } + + private void updateTabsTextColor() { + final int selected = mViewPager.getCurrentItem(); + final int children = mTabStrip.getChildCount(); + for (int i = 0; i < children; i++) { + final View view = mTabStrip.getChildAt(i); + if (view instanceof TextView) { + ((TextView) view).setTextColor(i == selected ? + mSelectedTabColor : mUnselectedTabColor); + } + } + } + + private class TabClickListener implements View.OnClickListener { + @Override + public void onClick(View v) { + for (int i = 0; i < mTabStrip.getChildCount(); i++) { + if (v == mTabStrip.getChildAt(i)) { + mViewPager.setCurrentItem(i); + return; + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingTabStrip.java b/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingTabStrip.java new file mode 100644 index 0000000000..ce244b60ec --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingTabStrip.java @@ -0,0 +1,164 @@ +package com.quran.labs.androidquran.widgets; + +/* + * Copyright (C) 2013 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. + */ + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.View; + +class SlidingTabStrip extends LeftToRightLinearLayout { + + private static final int DEFAULT_BOTTOM_BORDER_THICKNESS_DIPS = 0; + private static final byte DEFAULT_BOTTOM_BORDER_COLOR_ALPHA = 0x26; + private static final int SELECTED_INDICATOR_THICKNESS_DIPS = 3; + private static final int DEFAULT_SELECTED_INDICATOR_COLOR = 0xFF33B5E5; + + private final int mBottomBorderThickness; + private final Paint mBottomBorderPaint; + + private final int mSelectedIndicatorThickness; + private final Paint mSelectedIndicatorPaint; + + private int mSelectedPosition; + private float mSelectionOffset; + + private SlidingTabLayout.TabColorizer mCustomTabColorizer; + private final SimpleTabColorizer mDefaultTabColorizer; + + SlidingTabStrip(Context context) { + this(context, null); + } + + SlidingTabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + setWillNotDraw(false); + + final float density = getResources().getDisplayMetrics().density; + + TypedValue outValue = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.colorForeground, outValue, true); + final int themeForegroundColor = outValue.data; + + final int defaultBottomBorderColor = setColorAlpha(themeForegroundColor, + DEFAULT_BOTTOM_BORDER_COLOR_ALPHA); + + mDefaultTabColorizer = new SimpleTabColorizer(); + mDefaultTabColorizer.setIndicatorColors(DEFAULT_SELECTED_INDICATOR_COLOR); + + mBottomBorderThickness = (int) (DEFAULT_BOTTOM_BORDER_THICKNESS_DIPS * density); + mBottomBorderPaint = new Paint(); + mBottomBorderPaint.setColor(defaultBottomBorderColor); + + mSelectedIndicatorThickness = (int) (SELECTED_INDICATOR_THICKNESS_DIPS * density); + mSelectedIndicatorPaint = new Paint(); + } + + void setCustomTabColorizer(SlidingTabLayout.TabColorizer customTabColorizer) { + mCustomTabColorizer = customTabColorizer; + invalidate(); + } + + void setSelectedIndicatorColors(int... colors) { + // Make sure that the custom colorizer is removed + mCustomTabColorizer = null; + mDefaultTabColorizer.setIndicatorColors(colors); + invalidate(); + } + + void onViewPagerPageChanged(int position, float positionOffset) { + mSelectedPosition = position; + mSelectionOffset = positionOffset; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + final int height = getHeight(); + final int childCount = getChildCount(); + final SlidingTabLayout.TabColorizer tabColorizer = mCustomTabColorizer != null + ? mCustomTabColorizer + : mDefaultTabColorizer; + + // Thick colored underline below the current selection + if (childCount > 0) { + View selectedTitle = getChildAt(mSelectedPosition); + int left = selectedTitle.getLeft(); + int right = selectedTitle.getRight(); + int color = tabColorizer.getIndicatorColor(mSelectedPosition); + + if (mSelectionOffset > 0f && mSelectedPosition < (getChildCount() - 1)) { + int nextColor = tabColorizer.getIndicatorColor(mSelectedPosition + 1); + if (color != nextColor) { + color = blendColors(nextColor, color, mSelectionOffset); + } + + // Draw the selection partway between the tabs + View nextTitle = getChildAt(mSelectedPosition + 1); + left = (int) (mSelectionOffset * nextTitle.getLeft() + + (1.0f - mSelectionOffset) * left); + right = (int) (mSelectionOffset * nextTitle.getRight() + + (1.0f - mSelectionOffset) * right); + } + + mSelectedIndicatorPaint.setColor(color); + + canvas.drawRect(left, height - mSelectedIndicatorThickness, right, + height, mSelectedIndicatorPaint); + } + + // Thin underline along the entire bottom edge + canvas.drawRect(0, height - mBottomBorderThickness, getWidth(), height, mBottomBorderPaint); + } + + /** + * Set the alpha value of the {@code color} to be the given {@code alpha} value. + */ + private static int setColorAlpha(int color, byte alpha) { + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Blend {@code color1} and {@code color2} using the given ratio. + * + * @param ratio of which to blend. 1.0 will return {@code color1}, 0.5 will give an even blend, + * 0.0 will return {@code color2}. + */ + private static int blendColors(int color1, int color2, float ratio) { + final float inverseRation = 1f - ratio; + float r = (Color.red(color1) * ratio) + (Color.red(color2) * inverseRation); + float g = (Color.green(color1) * ratio) + (Color.green(color2) * inverseRation); + float b = (Color.blue(color1) * ratio) + (Color.blue(color2) * inverseRation); + return Color.rgb((int) r, (int) g, (int) b); + } + + private static class SimpleTabColorizer implements SlidingTabLayout.TabColorizer { + private int[] mIndicatorColors; + + @Override + public final int getIndicatorColor(int position) { + return mIndicatorColors[position % mIndicatorColors.length]; + } + + void setIndicatorColors(int... colors) { + mIndicatorColors = colors; + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingUpPanelLayout.java b/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingUpPanelLayout.java new file mode 100644 index 0000000000..9222c9fad0 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/SlidingUpPanelLayout.java @@ -0,0 +1,1339 @@ +/* + * Copyright (C) 2014 https://github.com/umano/AndroidSlidingUpPanel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modifications: + * - computeScroll(): check mDragHelper != null + * - rename above/below_shadow to sliding_panel_above/below_shadow + * - change slider sensitivity to 1.0 (sensitive) at end of constructor + * - onMeasure(): even if slider is GONE, still set the mSlideableView + * - add getSlideOffset() method to expose the slide offset + * - hidePane(): check if mSlideableView is already GONE + * - draw(): if mSlideableView is GONE, don't draw its shadow + * - Add option to allow dragging to arbitrary position (false by default) + * - DragHelperCallback.onViewReleased(): if yvel == 0 (i.e. drag, not fling), + * don't snap to top/bottom (to allow expanding to arbitrary positions) + * - fixed the spelling of parallax + * - removed lines needing NineOldAndroids, since we are now 14+ + * - fix production exception in onInterceptTouchEvent + * + */ + +package com.quran.labs.androidquran.widgets; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.content.ContextCompat; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.ViewCompat; +import android.support.v4.widget.ViewDragHelper; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.SoundEffectConstants; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; + +import com.quran.labs.androidquran.R; + +import timber.log.Timber; + +public class SlidingUpPanelLayout extends ViewGroup { + + /** + * Default peeking out panel height + */ + private static final int DEFAULT_PANEL_HEIGHT = 68; // dp; + + /** + * Default height of the shadow above the peeking out panel + */ + private static final int DEFAULT_SHADOW_HEIGHT = 4; // dp; + + /** + * If no fade color is given by default it will fade to 80% gray. + */ + private static final int DEFAULT_FADE_COLOR = 0x99000000; + + /** + * Default Minimum velocity that will be detected as a fling + */ + private static final int DEFAULT_MIN_FLING_VELOCITY = 400; // dips per second + /** + * Default is set to false because that is how it was written + */ + private static final boolean DEFAULT_OVERLAY_FLAG = false; + /** + * Default is set to false because that is how it was written + */ + private static final boolean DEFAULT_ARBITRARY_POS_FLAG = false; + /** + * Default attributes for layout + */ + private static final int[] DEFAULT_ATTRS = new int[] { + android.R.attr.gravity + }; + + /** + * Minimum velocity that will be detected as a fling + */ + private int mMinFlingVelocity = DEFAULT_MIN_FLING_VELOCITY; + + /** + * The fade color used for the panel covered by the slider. 0 = no fading. + */ + private int mCoveredFadeColor = DEFAULT_FADE_COLOR; + + /** + * Default parallax length of the main view + */ + private static final int DEFAULT_PARALLAX_OFFSET = 0; + + /** + * The paint used to dim the main layout when sliding + */ + private final Paint mCoveredFadePaint = new Paint(); + + /** + * Drawable used to draw the shadow between panes. + */ + private final Drawable mShadowDrawable; + + /** + * The size of the overhang in pixels. + */ + private int mPanelHeight = -1; + + /** + * The size of the shadow in pixels. + */ + private int mShadowHeight = -1; + + /** + * Parallax offset + */ + private int mParallaxOffset = -1; + + /** + * True if the collapsed panel should be dragged up. + */ + private boolean mIsSlidingUp; + + /** + * True if a panel can slide with the current measurements + */ + private boolean mCanSlide; + + /** + * Panel overlays the windows instead of putting it underneath it. + */ + private boolean mOverlayContent = DEFAULT_OVERLAY_FLAG; + + /** + * If provided, the panel can be dragged by only this view. Otherwise, the entire panel can be + * used for dragging. + */ + private View mDragView; + + /** + * If provided, the panel can be dragged by only this view. Otherwise, the entire panel can be + * used for dragging. + */ + private int mDragViewResId = -1; + + /** + * The child view that can slide, if any. + */ + private View mSlideableView; + + /** + * The main view + */ + private View mMainView; + + /** + * Current state of the slideable view. + */ + private enum SlideState { + EXPANDED, + COLLAPSED, + ANCHORED + } + private SlideState mSlideState = SlideState.COLLAPSED; + + /** + * How far the panel is offset from its expanded position. + * range [0, 1] where 0 = expanded, 1 = collapsed. + */ + private float mSlideOffset; + + /** + * How far in pixels the slideable panel may move. + */ + private int mSlideRange; + + /** + * A panel view is locked into internal scrolling or another condition that + * is preventing a drag. + */ + private boolean mIsUnableToDrag; + + /** + * Flag indicating that sliding feature is enabled\disabled + */ + private boolean mIsSlidingEnabled; + + /** + * Flag indicating that the sliding panel can be dragged anywhere + */ + private boolean mArbitraryPositionEnabled; + + /** + * Flag indicating if a drag view can have its own touch events. If set + * to true, a drag view can scroll horizontally and have its own click listener. + * + * Default is set to false. + */ + private boolean mIsUsingDragViewTouchEvents; + + /** + * Threshold to tell if there was a scroll touch event. + */ + private final int mScrollTouchSlop; + + private float mInitialMotionX; + private float mInitialMotionY; + private float mAnchorPoint = 0.f; + + private PanelSlideListener mPanelSlideListener; + + private final ViewDragHelper mDragHelper; + + /** + * Stores whether or not the pane was expanded the last time it was slideable. + * If expand/collapse operations are invoked this state is modified. Used by + * instance state save/restore. + */ + private boolean mFirstLayout = true; + + private final Rect mTmpRect = new Rect(); + + /** + * Listener for monitoring events about sliding panes. + */ + public interface PanelSlideListener { + /** + * Called when a sliding pane's position changes. + * @param panel The child view that was moved + * @param slideOffset The new offset of this sliding pane within its range, from 0-1 + */ + void onPanelSlide(View panel, float slideOffset); + /** + * Called when a sliding pane becomes slid completely collapsed. The pane may or may not + * be interactive at this point depending on if it's shown or hidden + * @param panel The child view that was slid to an collapsed position, revealing other panes + */ + void onPanelCollapsed(View panel); + + /** + * Called when a sliding pane becomes slid completely expanded. The pane is now guaranteed + * to be interactive. It may now obscure other views in the layout. + * @param panel The child view that was slid to a expanded position + */ + void onPanelExpanded(View panel); + + void onPanelAnchored(View panel); + } + + /** + * No-op stubs for {@link PanelSlideListener}. If you only want to implement a subset + * of the listener methods you can extend this instead of implement the full interface. + */ + public static class SimplePanelSlideListener implements PanelSlideListener { + @Override + public void onPanelSlide(View panel, float slideOffset) { + } + @Override + public void onPanelCollapsed(View panel) { + } + @Override + public void onPanelExpanded(View panel) { + } + @Override + public void onPanelAnchored(View panel) { + } + } + + public SlidingUpPanelLayout(Context context) { + this(context, null); + } + + public SlidingUpPanelLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SlidingUpPanelLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + if(isInEditMode()) { + mShadowDrawable = null; + mScrollTouchSlop = 0; + mDragHelper = null; + return; + } + + if (attrs != null) { + TypedArray defAttrs = context.obtainStyledAttributes(attrs, DEFAULT_ATTRS); + + if (defAttrs != null) { + int gravity = defAttrs.getInt(0, Gravity.NO_GRAVITY); + if (gravity != Gravity.TOP && gravity != Gravity.BOTTOM) { + throw new IllegalArgumentException("gravity must be set to either top or bottom"); + } + mIsSlidingUp = gravity == Gravity.BOTTOM; + } + + defAttrs.recycle(); + + TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.SlidingUpPanelLayout); + + if (ta != null) { + mPanelHeight = ta.getDimensionPixelSize(R.styleable.SlidingUpPanelLayout_panelHeight, -1); + mShadowHeight = ta.getDimensionPixelSize(R.styleable.SlidingUpPanelLayout_shadowHeight, -1); + mParallaxOffset = ta.getDimensionPixelSize(R.styleable.SlidingUpPanelLayout_parallaxOffset, -1); + + mMinFlingVelocity = ta.getInt(R.styleable.SlidingUpPanelLayout_flingVelocity, DEFAULT_MIN_FLING_VELOCITY); + mCoveredFadeColor = ta.getColor(R.styleable.SlidingUpPanelLayout_fadeColor, DEFAULT_FADE_COLOR); + + mDragViewResId = ta.getResourceId(R.styleable.SlidingUpPanelLayout_dragView, -1); + + mOverlayContent = ta.getBoolean(R.styleable.SlidingUpPanelLayout_overlay,DEFAULT_OVERLAY_FLAG); + + mArbitraryPositionEnabled = ta.getBoolean(R.styleable.SlidingUpPanelLayout_arbitraryPosition,DEFAULT_ARBITRARY_POS_FLAG); + } + + ta.recycle(); + } + + final float density = context.getResources().getDisplayMetrics().density; + if (mPanelHeight == -1) { + mPanelHeight = (int) (DEFAULT_PANEL_HEIGHT * density + 0.5f); + } + if (mShadowHeight == -1) { + mShadowHeight = (int) (DEFAULT_SHADOW_HEIGHT * density + 0.5f); + } + if (mParallaxOffset == -1) { + mParallaxOffset = (int) (DEFAULT_PARALLAX_OFFSET * density); + } + // If the shadow height is zero, don't show the shadow + if (mShadowHeight > 0) { + if (mIsSlidingUp) { + mShadowDrawable = ContextCompat.getDrawable(context, R.drawable.sliding_panel_above_shadow); + } else { + mShadowDrawable = ContextCompat.getDrawable(context, R.drawable.sliding_panel_below_shadow); + } + + } else { + mShadowDrawable = null; + } + + setWillNotDraw(false); + + mDragHelper = ViewDragHelper.create(this, 1.0f, new DragHelperCallback()); + mDragHelper.setMinVelocity(mMinFlingVelocity * density); + + mCanSlide = true; + mIsSlidingEnabled = true; + + ViewConfiguration vc = ViewConfiguration.get(context); + mScrollTouchSlop = vc.getScaledTouchSlop(); + } + + /** + * Set the Drag View after the view is inflated + */ + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + if (mDragViewResId != -1) { + mDragView = findViewById(mDragViewResId); + } + } + + /** + * Set the color used to fade the pane covered by the sliding pane out when the pane + * will become fully covered in the expanded state. + * + * @param color An ARGB-packed color value + */ + public void setCoveredFadeColor(int color) { + mCoveredFadeColor = color; + invalidate(); + } + + /** + * @return The ARGB-packed color value used to fade the fixed pane + */ + public int getCoveredFadeColor() { + return mCoveredFadeColor; + } + + /** + * Set sliding enabled flag + * @param enabled flag value + */ + public void setSlidingEnabled(boolean enabled) { + mIsSlidingEnabled = enabled; + } + + /** + * Set arbitrary position flag + * @param enabled flag value + */ + public void setArbitraryPositionEnabled(boolean enabled) { + mArbitraryPositionEnabled = enabled; + } + + /** + * Set the collapsed panel height in pixels + * + * @param val A height in pixels + */ + public void setPanelHeight(int val) { + mPanelHeight = val; + requestLayout(); + } + + /** + * @return The current collapsed panel height + */ + public int getPanelHeight() { + return mPanelHeight; + } + + /** + * @return The current parallax offset + */ + public int getCurrentParallaxOffset() { + int offset = (int)(mParallaxOffset * (1 - mSlideOffset)); + return mIsSlidingUp ? -offset : offset; + } + + /** + * Sets the panel slide listener + * @param listener + */ + public void setPanelSlideListener(PanelSlideListener listener) { + mPanelSlideListener = listener; + } + + /** + * Set the draggable view portion. Use to null, to allow the whole panel to be draggable + * + * @param dragView A view that will be used to drag the panel. + */ + public void setDragView(View dragView) { + mDragView = dragView; + } + + /** + * Set an anchor point where the panel can stop during sliding + * + * @param anchorPoint A value between 0 and 1, determining the position of the anchor point + * starting from the top of the layout. + */ + public void setAnchorPoint(float anchorPoint) { + if (anchorPoint > 0 && anchorPoint < 1) + mAnchorPoint = anchorPoint; + } + + /** + * Sets whether or not the panel overlays the content + * @param overlayed + */ + public void setOverlayed(boolean overlayed) { + mOverlayContent = overlayed; + } + + /** + * Check if the panel is set as an overlay. + */ + public boolean isOverlayed() { + return mOverlayContent; + } + + void dispatchOnPanelSlide(View panel) { + if (mPanelSlideListener != null) { + mPanelSlideListener.onPanelSlide(panel, mSlideOffset); + } + } + + void dispatchOnPanelExpanded(View panel) { + if (mPanelSlideListener != null) { + mPanelSlideListener.onPanelExpanded(panel); + } + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + } + + void dispatchOnPanelCollapsed(View panel) { + if (mPanelSlideListener != null) { + mPanelSlideListener.onPanelCollapsed(panel); + } + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + } + + void dispatchOnPanelAnchored(View panel) { + if (mPanelSlideListener != null) { + mPanelSlideListener.onPanelAnchored(panel); + } + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + } + + void updateObscuredViewVisibility() { + if (getChildCount() == 0) { + return; + } + final int leftBound = getPaddingLeft(); + final int rightBound = getWidth() - getPaddingRight(); + final int topBound = getPaddingTop(); + final int bottomBound = getHeight() - getPaddingBottom(); + final int left; + final int right; + final int top; + final int bottom; + if (mSlideableView != null && hasOpaqueBackground(mSlideableView)) { + left = mSlideableView.getLeft(); + right = mSlideableView.getRight(); + top = mSlideableView.getTop(); + bottom = mSlideableView.getBottom(); + } else { + left = right = top = bottom = 0; + } + View child = getChildAt(0); + final int clampedChildLeft = Math.max(leftBound, child.getLeft()); + final int clampedChildTop = Math.max(topBound, child.getTop()); + final int clampedChildRight = Math.min(rightBound, child.getRight()); + final int clampedChildBottom = Math.min(bottomBound, child.getBottom()); + final int vis; + if (clampedChildLeft >= left && clampedChildTop >= top && + clampedChildRight <= right && clampedChildBottom <= bottom) { + vis = INVISIBLE; + } else { + vis = VISIBLE; + } + child.setVisibility(vis); + } + + void setAllChildrenVisible() { + for (int i = 0, childCount = getChildCount(); i < childCount; i++) { + final View child = getChildAt(i); + if (child.getVisibility() == INVISIBLE) { + child.setVisibility(VISIBLE); + } + } + } + + private static boolean hasOpaqueBackground(View v) { + final Drawable bg = v.getBackground(); + return bg != null && bg.getOpacity() == PixelFormat.OPAQUE; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mFirstLayout = true; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mFirstLayout = true; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int widthMode = MeasureSpec.getMode(widthMeasureSpec); + final int widthSize = MeasureSpec.getSize(widthMeasureSpec); + final int heightMode = MeasureSpec.getMode(heightMeasureSpec); + final int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + if (widthMode != MeasureSpec.EXACTLY) { + throw new IllegalStateException("Width must have an exact value or MATCH_PARENT"); + } else if (heightMode != MeasureSpec.EXACTLY) { + throw new IllegalStateException("Height must have an exact value or MATCH_PARENT"); + } + + int layoutHeight = heightSize - getPaddingTop() - getPaddingBottom(); + int panelHeight = mPanelHeight; + + final int childCount = getChildCount(); + + if (childCount > 2) { + Timber.e("onMeasure: More than two child views are not supported."); + } else if (getChildAt(1).getVisibility() == GONE) { + panelHeight = 0; + } + + // We'll find the current one below. + mSlideableView = null; + mCanSlide = false; + + // First pass. Measure based on child LayoutParams width/height. + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + int height = layoutHeight; + if (child.getVisibility() == GONE) { + lp.dimWhenOffset = false; + if (i == 1) mSlideableView = child; + continue; + } + + if (i == 1) { + lp.slideable = true; + lp.dimWhenOffset = true; + mSlideableView = child; + mCanSlide = true; + } else { + if (!mOverlayContent) { + height -= panelHeight; + } + mMainView = child; + } + + int childWidthSpec; + if (lp.width == LayoutParams.WRAP_CONTENT) { + childWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.AT_MOST); + } else if (lp.width == LayoutParams.MATCH_PARENT) { + childWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); + } else { + childWidthSpec = MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY); + } + + int childHeightSpec; + if (lp.height == LayoutParams.WRAP_CONTENT) { + childHeightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST); + } else if (lp.height == LayoutParams.MATCH_PARENT) { + childHeightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); + } else { + childHeightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); + } + + child.measure(childWidthSpec, childHeightSpec); + } + + setMeasuredDimension(widthSize, heightSize); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int paddingLeft = getPaddingLeft(); + final int paddingTop = getPaddingTop(); + final int slidingTop = getSlidingTop(); + + final int childCount = getChildCount(); + + if (mFirstLayout) { + switch (mSlideState) { + case EXPANDED: + mSlideOffset = mCanSlide ? 0.f : 1.f; + break; + case ANCHORED: + mSlideOffset = mCanSlide ? mAnchorPoint : 1.f; + break; + default: + mSlideOffset = 1.f; + break; + } + } + + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + if (child.getVisibility() == GONE) { + continue; + } + + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + final int childHeight = child.getMeasuredHeight(); + + if (lp.slideable) { + mSlideRange = childHeight - mPanelHeight; + } + + int childTop; + if (mIsSlidingUp) { + childTop = lp.slideable ? slidingTop + (int) (mSlideRange * mSlideOffset) : paddingTop; + } else { + childTop = lp.slideable ? slidingTop - (int) (mSlideRange * mSlideOffset) : paddingTop; + if (!lp.slideable && !mOverlayContent) { + childTop += mPanelHeight; + } + } + final int childBottom = childTop + childHeight; + final int childLeft = paddingLeft; + final int childRight = childLeft + child.getMeasuredWidth(); + + child.layout(childLeft, childTop, childRight, childBottom); + } + + if (mFirstLayout) { + updateObscuredViewVisibility(); + } + + mFirstLayout = false; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + // Recalculate sliding panes and their details + if (h != oldh) { + mFirstLayout = true; + } + } + + /** + * Set if the drag view can have its own touch events. If set + * to true, a drag view can scroll horizontally and have its own click listener. + * + * Default is set to false. + */ + public void setEnableDragViewTouchEvents(boolean enabled) { + mIsUsingDragViewTouchEvents = enabled; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final int action = MotionEventCompat.getActionMasked(ev); + + if (!mCanSlide || !mIsSlidingEnabled || (mIsUnableToDrag && action != MotionEvent.ACTION_DOWN)) { + mDragHelper.cancel(); + return super.onInterceptTouchEvent(ev); + } + + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + mDragHelper.cancel(); + return false; + } + + final float x = ev.getX(); + final float y = ev.getY(); + boolean interceptTap = false; + + switch (action) { + case MotionEvent.ACTION_DOWN: { + mIsUnableToDrag = false; + mInitialMotionX = x; + mInitialMotionY = y; + if (isDragViewUnder((int) x, (int) y) && !mIsUsingDragViewTouchEvents) { + interceptTap = true; + } + break; + } + + case MotionEvent.ACTION_MOVE: { + final float adx = Math.abs(x - mInitialMotionX); + final float ady = Math.abs(y - mInitialMotionY); + final int dragSlop = mDragHelper.getTouchSlop(); + + // Handle any horizontal scrolling on the drag view. + if (mIsUsingDragViewTouchEvents) { + if (adx > mScrollTouchSlop && ady < mScrollTouchSlop) { + return super.onInterceptTouchEvent(ev); + } + // Intercept the touch if the drag view has any vertical scroll. + // onTouchEvent will determine if the view should drag vertically. + else if (ady > mScrollTouchSlop) { + interceptTap = isDragViewUnder((int) x, (int) y); + } + } + + if ((ady > dragSlop && adx > ady) || !isDragViewUnder((int) x, (int) y)) { + mDragHelper.cancel(); + mIsUnableToDrag = true; + return false; + } + break; + } + } + + // fixing production exception + try { + final boolean interceptForDrag = mDragHelper.shouldInterceptTouchEvent(ev); + return interceptForDrag || interceptTap; + } catch (ArrayIndexOutOfBoundsException ae) { + return interceptTap; + } catch (NullPointerException npe) { + return interceptTap; + } + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (!mCanSlide || !mIsSlidingEnabled) { + return super.onTouchEvent(ev); + } + + mDragHelper.processTouchEvent(ev); + + final int action = ev.getAction(); + boolean wantTouchEvents = true; + + switch (action & MotionEventCompat.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: { + final float x = ev.getX(); + final float y = ev.getY(); + mInitialMotionX = x; + mInitialMotionY = y; + break; + } + + case MotionEvent.ACTION_UP: { + final float x = ev.getX(); + final float y = ev.getY(); + final float dx = x - mInitialMotionX; + final float dy = y - mInitialMotionY; + final int slop = mDragHelper.getTouchSlop(); + View dragView = mDragView != null ? mDragView : mSlideableView; + if (dx * dx + dy * dy < slop * slop && + isDragViewUnder((int) x, (int) y)) { + dragView.playSoundEffect(SoundEffectConstants.CLICK); + if (!isExpanded() && !isAnchored()) { + expandPane(mAnchorPoint); + } else { + collapsePane(); + } + break; + } + break; + } + } + + return wantTouchEvents; + } + + private boolean isDragViewUnder(int x, int y) { + View dragView = mDragView != null ? mDragView : mSlideableView; + if (dragView == null) return false; + int[] viewLocation = new int[2]; + dragView.getLocationOnScreen(viewLocation); + int[] parentLocation = new int[2]; + this.getLocationOnScreen(parentLocation); + int screenX = parentLocation[0] + x; + int screenY = parentLocation[1] + y; + return screenX >= viewLocation[0] && screenX < viewLocation[0] + dragView.getWidth() && + screenY >= viewLocation[1] && screenY < viewLocation[1] + dragView.getHeight(); + } + + private boolean expandPane(View pane, int initialVelocity, float mSlideOffset) { + return mFirstLayout || smoothSlideTo(mSlideOffset, initialVelocity); + } + + private boolean collapsePane(View pane, int initialVelocity) { + return mFirstLayout || smoothSlideTo(1.f, initialVelocity); + } + + private int getSlidingTop() { + if (mSlideableView != null) { + return mIsSlidingUp + ? getMeasuredHeight() - getPaddingBottom() - mSlideableView.getMeasuredHeight() + : getPaddingTop(); + } + + return getMeasuredHeight() - getPaddingBottom(); + } + + /** + * Collapse the sliding pane if it is currently slideable. If first layout + * has already completed this will animate. + * + * @return true if the pane was slideable and is now collapsed/in the process of collapsing + */ + public boolean collapsePane() { + return collapsePane(mSlideableView, 0); + } + + /** + * Expand the sliding pane if it is currently slideable. If first layout + * has already completed this will animate. + * + * @return true if the pane was slideable and is now expanded/in the process of expading + */ + public boolean expandPane() { + return expandPane(0); + } + + /** + * Partially expand the sliding pane up to a specific offset + * + * @param mSlideOffset Value between 0 and 1, where 0 is completely expanded. + * @return true if the pane was slideable and is now expanded/in the process of expading + */ + public boolean expandPane(float mSlideOffset) { + if (!isPaneVisible()) { + showPane(); + } + return expandPane(mSlideableView, 0, mSlideOffset); + } + + public float getSlideOffset() { + return mSlideOffset; + } + + /** + * Check if the layout is completely expanded. + * + * @return true if sliding panels are completely expanded + */ + public boolean isExpanded() { + return mSlideState == SlideState.EXPANDED; + } + + /** + * Check if the layout is anchored in an intermediate point. + * + * @return true if sliding panels are anchored + */ + public boolean isAnchored() { + return mSlideState == SlideState.ANCHORED; + } + + /** + * Check if the content in this layout cannot fully fit side by side and therefore + * the content pane can be slid back and forth. + * + * @return true if content in this layout can be expanded + */ + public boolean isSlideable() { + return mCanSlide; + } + + public boolean isPaneVisible() { + if (getChildCount() < 2) { + return false; + } + View slidingPane = getChildAt(1); + return slidingPane.getVisibility() == View.VISIBLE; + } + + public void showPane() { + if (getChildCount() < 2) { + return; + } + View slidingPane = getChildAt(1); + slidingPane.setVisibility(View.VISIBLE); + requestLayout(); + } + + public void hidePane() { + if (mSlideableView == null || mSlideableView.getVisibility() == GONE) { + return; + } + mSlideableView.setVisibility(View.GONE); + requestLayout(); + } + + private void onPanelDragged(int newTop) { + final int topBound = getSlidingTop(); + mSlideOffset = mIsSlidingUp + ? (float) (newTop - topBound) / mSlideRange + : (float) (topBound - newTop) / mSlideRange; + dispatchOnPanelSlide(mSlideableView); + + if (mParallaxOffset > 0) { + int mainViewOffset = getCurrentParallaxOffset(); + moveMainView(mainViewOffset); + } + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private void moveMainView(int mainViewOffset) { + mMainView.setTranslationY(mainViewOffset); + } + + @Override + protected boolean drawChild(Canvas canvas, View child, long drawingTime) { + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + boolean result; + final int save = canvas.save(Canvas.CLIP_SAVE_FLAG); + + boolean drawScrim = false; + + if (mCanSlide && !lp.slideable && mSlideableView != null) { + // Clip against the slider; no sense drawing what will immediately be covered, + // Unless the panel is set to overlay content + if (!mOverlayContent) { + canvas.getClipBounds(mTmpRect); + if (mIsSlidingUp) { + mTmpRect.bottom = Math.min(mTmpRect.bottom, mSlideableView.getTop()); + } else { + mTmpRect.top = Math.max(mTmpRect.top, mSlideableView.getBottom()); + } + canvas.clipRect(mTmpRect); + } + if (mSlideOffset < 1) { + drawScrim = true; + } + } + + result = super.drawChild(canvas, child, drawingTime); + canvas.restoreToCount(save); + + if (drawScrim) { + final int baseAlpha = (mCoveredFadeColor & 0xff000000) >>> 24; + final int imag = (int) (baseAlpha * (1 - mSlideOffset)); + final int color = imag << 24 | (mCoveredFadeColor & 0xffffff); + mCoveredFadePaint.setColor(color); + canvas.drawRect(mTmpRect, mCoveredFadePaint); + } + + return result; + } + + /** + * Smoothly animate mDraggingPane to the target X position within its range. + * + * @param slideOffset position to animate to + * @param velocity initial velocity in case of fling, or 0. + */ + boolean smoothSlideTo(float slideOffset, int velocity) { + if (!mCanSlide) { + // Nothing to do. + return false; + } + + final int topBound = getSlidingTop(); + int y = mIsSlidingUp + ? (int) (topBound + slideOffset * mSlideRange) + : (int) (topBound - slideOffset * mSlideRange); + + if (mDragHelper.smoothSlideViewTo(mSlideableView, mSlideableView.getLeft(), y)) { + setAllChildrenVisible(); + ViewCompat.postInvalidateOnAnimation(this); + return true; + } + return false; + } + + @Override + public void computeScroll() { + if (mDragHelper != null && mDragHelper.continueSettling(true)) { + if (!mCanSlide) { + mDragHelper.abort(); + return; + } + + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + public void draw(Canvas c) { + super.draw(c); + + if (mSlideableView == null || mSlideableView.getVisibility() == GONE) { + // No need to draw a shadow if we don't have one. + return; + } + + final int right = mSlideableView.getRight(); + final int top; + final int bottom; + if (mIsSlidingUp) { + top = mSlideableView.getTop() - mShadowHeight; + bottom = mSlideableView.getTop(); + } else { + top = mSlideableView.getBottom(); + bottom = mSlideableView.getBottom() + mShadowHeight; + } + final int left = mSlideableView.getLeft(); + + if (mShadowDrawable != null) { + mShadowDrawable.setBounds(left, top, right, bottom); + mShadowDrawable.draw(c); + } + } + + /** + * Tests scrollability within child views of v given a delta of dx. + * + * @param v View to test for horizontal scrollability + * @param checkV Whether the view v passed should itself be checked for scrollability (true), + * or just its children (false). + * @param dx Delta scrolled in pixels + * @param x X coordinate of the active touch point + * @param y Y coordinate of the active touch point + * @return true if child views of v can be scrolled by delta of dx. + */ + protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) { + if (v instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) v; + final int scrollX = v.getScrollX(); + final int scrollY = v.getScrollY(); + final int count = group.getChildCount(); + // Count backwards - let topmost views consume scroll distance first. + for (int i = count - 1; i >= 0; i--) { + final View child = group.getChildAt(i); + if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() && + y + scrollY >= child.getTop() && y + scrollY < child.getBottom() && + canScroll(child, true, dx, x + scrollX - child.getLeft(), + y + scrollY - child.getTop())) { + return true; + } + } + } + return checkV && ViewCompat.canScrollHorizontally(v, -dx); + } + + + @Override + protected ViewGroup.LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(); + } + + @Override + protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + return p instanceof MarginLayoutParams + ? new LayoutParams((MarginLayoutParams) p) + : new LayoutParams(p); + } + + @Override + protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { + return p instanceof LayoutParams && super.checkLayoutParams(p); + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + + SavedState ss = new SavedState(superState); + ss.mSlideState = mSlideState; + + return ss; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + mSlideState = ss.mSlideState; + } + + private class DragHelperCallback extends ViewDragHelper.Callback { + + @Override + public boolean tryCaptureView(View child, int pointerId) { + if (mIsUnableToDrag) { + return false; + } + + return ((LayoutParams) child.getLayoutParams()).slideable; + } + + @Override + public void onViewDragStateChanged(int state) { + int anchoredTop = (int)(mAnchorPoint*mSlideRange); + + if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_IDLE) { + if (mSlideOffset == 0) { + if (mSlideState != SlideState.EXPANDED) { + updateObscuredViewVisibility(); + dispatchOnPanelExpanded(mSlideableView); + mSlideState = SlideState.EXPANDED; + } + } else if (mSlideOffset == (float)anchoredTop/(float)mSlideRange) { + if (mSlideState != SlideState.ANCHORED) { + updateObscuredViewVisibility(); + dispatchOnPanelAnchored(mSlideableView); + mSlideState = SlideState.ANCHORED; + } + } else if (mSlideState != SlideState.COLLAPSED) { + dispatchOnPanelCollapsed(mSlideableView); + mSlideState = SlideState.COLLAPSED; + } + } + } + + @Override + public void onViewCaptured(View capturedChild, int activePointerId) { + // Make all child views visible in preparation for sliding things around + setAllChildrenVisible(); + } + + @Override + public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { + onPanelDragged(top); + invalidate(); + } + + @Override + public void onViewReleased(View releasedChild, float xvel, float yvel) { + int top = mIsSlidingUp + ? getSlidingTop() + : getSlidingTop() - mSlideRange; + + if (mAnchorPoint != 0) { + int anchoredTop; + float anchorOffset; + if (mIsSlidingUp) { + anchoredTop = (int)(mAnchorPoint*mSlideRange); + anchorOffset = (float)anchoredTop/(float)mSlideRange; + } else { + anchoredTop = mPanelHeight - (int)(mAnchorPoint*mSlideRange); + anchorOffset = (float)(mPanelHeight - anchoredTop)/(float)mSlideRange; + } + + if (yvel > 0 || (yvel == 0 && mSlideOffset >= (1f+anchorOffset)/2)) { + top += mSlideRange; + } else if (yvel == 0 && mSlideOffset < (1f+anchorOffset)/2 + && mSlideOffset >= anchorOffset/2) { + top += mSlideRange * mAnchorPoint; + } + + } else if (yvel > 0 || (yvel == 0 && mSlideOffset > 0.5f)) { + top += mSlideRange; + } + + // If arbitrary position enabled, don't snap to position + if (mArbitraryPositionEnabled && yvel == 0) { + top = releasedChild.getTop(); + } + + mDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top); + invalidate(); + } + + @Override + public int getViewVerticalDragRange(View child) { + return mSlideRange; + } + + @Override + public int clampViewPositionVertical(View child, int top, int dy) { + final int topBound; + final int bottomBound; + if (mIsSlidingUp) { + topBound = getSlidingTop(); + bottomBound = topBound + mSlideRange; + } else { + bottomBound = getPaddingTop(); + topBound = bottomBound - mSlideRange; + } + + return Math.min(Math.max(top, topBound), bottomBound); + } + } + + public static class LayoutParams extends MarginLayoutParams { + private static final int[] ATTRS = new int[] { + android.R.attr.layout_weight + }; + + /** + * True if this pane is the slideable pane in the layout. + */ + boolean slideable; + + /** + * True if this view should be drawn dimmed + * when it's been offset from its default position. + */ + boolean dimWhenOffset; + + Paint dimPaint; + + public LayoutParams() { + super(MATCH_PARENT, MATCH_PARENT); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + + public LayoutParams(MarginLayoutParams source) { + super(source); + } + + public LayoutParams(LayoutParams source) { + super(source); + } + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + + final TypedArray a = c.obtainStyledAttributes(attrs, ATTRS); + a.recycle(); + } + + } + + static class SavedState extends BaseSavedState { + SlideState mSlideState; + + SavedState(Parcelable superState) { + super(superState); + } + + private SavedState(Parcel in) { + super(in); + try { + mSlideState = Enum.valueOf(SlideState.class, in.readString()); + } catch (IllegalArgumentException e) { + mSlideState = SlideState.COLLAPSED; + } + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeString(mSlideState.toString()); + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/TabletView.java b/app/src/main/java/com/quran/labs/androidquran/widgets/TabletView.java new file mode 100644 index 0000000000..c808524cfb --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/TabletView.java @@ -0,0 +1,109 @@ +package com.quran.labs.androidquran.widgets; + +import android.content.Context; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; + +import com.quran.labs.androidquran.ui.util.PageController; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +public class TabletView extends QuranPageWrapperLayout { + public static final int QURAN_PAGE = 1; + public static final int TRANSLATION_PAGE = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( { QURAN_PAGE, TRANSLATION_PAGE } ) + @interface TabletPageType {} + + private Context context; + private QuranPageLayout leftPage; + private QuranPageLayout rightPage; + private PageController pageController; + + public TabletView(Context context) { + super(context); + this.context = context; + } + + public void init(@TabletPageType int leftPageType, @TabletPageType int rightPageType) { + leftPage = getPageLayout(leftPageType); + rightPage = getPageLayout(rightPageType); + + addView(leftPage); + addView(rightPage); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int pageWidth = MeasureSpec.makeMeasureSpec(getMeasuredWidth() / 2, MeasureSpec.EXACTLY); + int pageHeight = MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY); + leftPage.measure(pageWidth, pageHeight); + rightPage.measure(pageWidth, pageHeight); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + final int width = getMeasuredWidth(); + final int height = getMeasuredHeight(); + leftPage.layout(0, 0, width / 2, height); + rightPage.layout(width / 2, 0, width, height); + } + + private QuranPageLayout getPageLayout(@TabletPageType int type) { + if (type == TRANSLATION_PAGE) { + return new QuranTranslationPageLayout(context); + } else { + return new QuranTabletImagePageLayout(context); + } + } + + public void setPageController(PageController controller, int leftPage, int rightPage) { + this.pageController = controller; + this.leftPage.setPageController(controller, leftPage); + this.rightPage.setPageController(controller, rightPage); + } + + @Override + public void updateView(@NonNull QuranSettings quranSettings) { + super.updateView(quranSettings); + leftPage.updateView(quranSettings); + rightPage.updateView(quranSettings); + } + + @Override + public void showError(@StringRes int errorRes) { + super.showError(errorRes); + rightPage.shouldHideLine = true; + rightPage.invalidate(); + } + + @Override + public void hideError() { + super.hideError(); + rightPage.shouldHideLine = false; + rightPage.invalidate(); + } + + public QuranPageLayout getLeftPage() { + return leftPage; + } + + public QuranPageLayout getRightPage() { + return rightPage; + } + + @Override + void handleRetryClicked() { + if (pageController != null) { + rightPage.shouldHideLine = false; + rightPage.invalidate(); + pageController.handleRetryClicked(); + } + } +} diff --git a/app/src/main/java/com/quran/labs/androidquran/widgets/TagsViewGroup.java b/app/src/main/java/com/quran/labs/androidquran/widgets/TagsViewGroup.java new file mode 100644 index 0000000000..b550889767 --- /dev/null +++ b/app/src/main/java/com/quran/labs/androidquran/widgets/TagsViewGroup.java @@ -0,0 +1,132 @@ +package com.quran.labs.androidquran.widgets; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Resources; +import android.os.Build; +import android.support.v4.content.ContextCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.quran.labs.androidquran.R; +import com.quran.labs.androidquran.dao.Tag; +import com.quran.labs.androidquran.util.QuranSettings; + +import java.util.List; + +public class TagsViewGroup extends LinearLayout { + private static final int MAX_TAGS = 6; + + private int mTagWidth; + private int mTagsToShow; + private int mTagsMargin; + private int mTagsTextSize; + private int mDefaultTagBackgroundColor; + private boolean mIsRtl; + private Context mContext; + + private List mTags; + + public TagsViewGroup(Context context) { + this(context, null); + } + + public TagsViewGroup(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public TagsViewGroup(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public TagsViewGroup(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context); + } + + private void init(Context context) { + mContext = context; + + Resources resources = context.getResources(); + mTagWidth = resources.getDimensionPixelSize(R.dimen.tag_width); + mTagsMargin = resources.getDimensionPixelSize(R.dimen.tag_margin); + mTagsTextSize = resources.getDimensionPixelSize(R.dimen.tag_text_size); + mDefaultTagBackgroundColor = ContextCompat.getColor(context, R.color.accent_color_dark); + mTagsToShow = MAX_TAGS; + mIsRtl = QuranSettings.getInstance(context).isArabicNames(); + } + + public void setTags(List tags) { + removeAllViews(); + mTags = tags; + + for (int i = 0, tagsSize = tags.size(); i < tagsSize; i++) { + Tag tag = tags.get(i); + LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams(mTagWidth, ViewGroup.LayoutParams.WRAP_CONTENT); + params.gravity = Gravity.CENTER; + setLeftRightMargin(params, i, Math.min(tagsSize, MAX_TAGS) - 1); + + TextView tv = new TextView(mContext); + tv.setText(tag.name); + tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTagsTextSize); + tv.setBackgroundColor(mDefaultTagBackgroundColor); + tv.setGravity(Gravity.CENTER); + tv.setLines(1); + tv.setEllipsize(TextUtils.TruncateAt.END); + addView(tv, params); + } + requestLayout(); + } + + private void setLeftRightMargin(LayoutParams params, int position, int maxPosition) { + if (position == 0) { + setStartMargin(params, 0); + setEndMargin(params, maxPosition == 0 ? 0 : mTagsMargin); + } else if (position == maxPosition) { + setStartMargin(params, mTagsMargin); + setEndMargin(params, 0); + } else { + setStartMargin(params, mTagsMargin); + setEndMargin(params, mTagsMargin); + } + } + + private void setStartMargin(LayoutParams params, int value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + params.setMarginStart(value); + } else if (mIsRtl) { + params.rightMargin = value; + } else { + params.leftMargin = value; + } + } + + private void setEndMargin(LayoutParams params, int value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + params.setMarginEnd(value); + } else if (mIsRtl) { + params.leftMargin = value; + } else { + params.rightMargin = value; + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + mTagsToShow = MeasureSpec.getSize(widthMeasureSpec) / (mTagWidth + 2 * mTagsMargin); + mTagsToShow = Math.min(mTagsToShow, mTags.isEmpty() ? mTagsToShow : mTags.size()); + int width = ((mTagsToShow - 1) * 2 * mTagsMargin) + (mTagsToShow * mTagWidth); + setMeasuredDimension(width, MeasureSpec.getSize(heightMeasureSpec)); + } + } +} diff --git a/app/src/main/res/color/sherlock_primary_text.xml b/app/src/main/res/color/sherlock_primary_text.xml new file mode 100644 index 0000000000..b474fc4fec --- /dev/null +++ b/app/src/main/res/color/sherlock_primary_text.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/app/src/main/res/color/sherlock_primary_text_disable.xml b/app/src/main/res/color/sherlock_primary_text_disable.xml new file mode 100644 index 0000000000..efb8d0726d --- /dev/null +++ b/app/src/main/res/color/sherlock_primary_text_disable.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable-hdpi/bookmark_currentpage.png b/app/src/main/res/drawable-hdpi/bookmark_currentpage.png new file mode 100644 index 0000000000..dea767a9ca Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bookmark_currentpage.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_ab_search.png b/app/src/main/res/drawable-hdpi/ic_ab_search.png new file mode 100644 index 0000000000..a2fc5b2e70 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_ab_search.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_accept.png b/app/src/main/res/drawable-hdpi/ic_accept.png new file mode 100644 index 0000000000..f42a0e2d23 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_accept.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_expand.png b/app/src/main/res/drawable-hdpi/ic_action_expand.png new file mode 100644 index 0000000000..6c24766600 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_expand.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_settings.png b/app/src/main/res/drawable-hdpi/ic_action_settings.png new file mode 100644 index 0000000000..f9a8915fd2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_cancel.png b/app/src/main/res/drawable-hdpi/ic_cancel.png new file mode 100644 index 0000000000..0fd15563a2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_copy.png b/app/src/main/res/drawable-hdpi/ic_copy.png new file mode 100644 index 0000000000..03b1aac4e0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_copy.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_day_mode.png b/app/src/main/res/drawable-hdpi/ic_day_mode.png new file mode 100644 index 0000000000..6fc38c0cbc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_day_mode.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_download.png b/app/src/main/res/drawable-hdpi/ic_download.png new file mode 100644 index 0000000000..46aeab4580 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_download.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_edit.png b/app/src/main/res/drawable-hdpi/ic_edit.png new file mode 100644 index 0000000000..730416c96a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_favorite.png b/app/src/main/res/drawable-hdpi/ic_favorite.png new file mode 100644 index 0000000000..66d4223d1e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_favorite.png differ diff --git a/res/drawable/bookmarks.png b/app/src/main/res/drawable-hdpi/ic_goto_quran.png similarity index 80% rename from res/drawable/bookmarks.png rename to app/src/main/res/drawable-hdpi/ic_goto_quran.png index e5ae478195..07f118a7ec 100644 Binary files a/res/drawable/bookmarks.png and b/app/src/main/res/drawable-hdpi/ic_goto_quran.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_link.png b/app/src/main/res/drawable-hdpi/ic_link.png new file mode 100644 index 0000000000..4186e00c65 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_new.png b/app/src/main/res/drawable-hdpi/ic_new.png new file mode 100644 index 0000000000..481643ecd5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_new.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_next.png b/app/src/main/res/drawable-hdpi/ic_next.png new file mode 100644 index 0000000000..4eaf7caab0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_next.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_night_mode.png b/app/src/main/res/drawable-hdpi/ic_night_mode.png new file mode 100644 index 0000000000..cf51aa0f97 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_night_mode.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_not_favorite.png b/app/src/main/res/drawable-hdpi/ic_not_favorite.png new file mode 100644 index 0000000000..b29d9a21ad Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_not_favorite.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pause.png b/app/src/main/res/drawable-hdpi/ic_pause.png new file mode 100644 index 0000000000..b4bdbb5588 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_play.png b/app/src/main/res/drawable-hdpi/ic_play.png new file mode 100644 index 0000000000..164385d047 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_previous.png b/app/src/main/res/drawable-hdpi/ic_previous.png new file mode 100644 index 0000000000..e59dedb62b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_previous.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_repeat.png b/app/src/main/res/drawable-hdpi/ic_repeat.png new file mode 100644 index 0000000000..612e73458c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_repeat.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_share.png b/app/src/main/res/drawable-hdpi/ic_share.png new file mode 100644 index 0000000000..93b3c219c6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_sort.png b/app/src/main/res/drawable-hdpi/ic_sort.png new file mode 100644 index 0000000000..25c2dad67c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_stop.png b/app/src/main/res/drawable-hdpi/ic_stop.png new file mode 100644 index 0000000000..135d367c8c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_tag.png b/app/src/main/res/drawable-hdpi/ic_tag.png new file mode 100644 index 0000000000..6721eceadb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_tag.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_translation.png b/app/src/main/res/drawable-hdpi/ic_translation.png new file mode 100644 index 0000000000..bd920008c0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_translation.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_translation_left.png b/app/src/main/res/drawable-hdpi/ic_translation_left.png new file mode 100644 index 0000000000..86fa3379ff Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_translation_left.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_translation_right.png b/app/src/main/res/drawable-hdpi/ic_translation_right.png new file mode 100644 index 0000000000..d5f6cee246 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_translation_right.png differ diff --git a/app/src/main/res/drawable-mdpi/bookmark_currentpage.png b/app/src/main/res/drawable-mdpi/bookmark_currentpage.png new file mode 100644 index 0000000000..892440210f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bookmark_currentpage.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_ab_search.png b/app/src/main/res/drawable-mdpi/ic_ab_search.png new file mode 100644 index 0000000000..dff1e3a8a4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_ab_search.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_accept.png b/app/src/main/res/drawable-mdpi/ic_accept.png new file mode 100644 index 0000000000..e91f9048bd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_accept.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_expand.png b/app/src/main/res/drawable-mdpi/ic_action_expand.png new file mode 100644 index 0000000000..61e551f1f5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_expand.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_settings.png b/app/src/main/res/drawable-mdpi/ic_action_settings.png new file mode 100644 index 0000000000..fdcf657fad Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_cancel.png b/app/src/main/res/drawable-mdpi/ic_cancel.png new file mode 100644 index 0000000000..e80681aeb7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_copy.png b/app/src/main/res/drawable-mdpi/ic_copy.png new file mode 100644 index 0000000000..6aa238c562 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_copy.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_day_mode.png b/app/src/main/res/drawable-mdpi/ic_day_mode.png new file mode 100644 index 0000000000..2658aec5ae Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_day_mode.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_download.png b/app/src/main/res/drawable-mdpi/ic_download.png new file mode 100644 index 0000000000..e089466de5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_download.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_edit.png b/app/src/main/res/drawable-mdpi/ic_edit.png new file mode 100644 index 0000000000..85cff0b919 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_favorite.png b/app/src/main/res/drawable-mdpi/ic_favorite.png new file mode 100644 index 0000000000..7b4e65d71c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_favorite.png differ diff --git a/res/drawable/translation_book.png b/app/src/main/res/drawable-mdpi/ic_goto_quran.png similarity index 75% rename from res/drawable/translation_book.png rename to app/src/main/res/drawable-mdpi/ic_goto_quran.png index bc1ac17c68..20584cdcb7 100644 Binary files a/res/drawable/translation_book.png and b/app/src/main/res/drawable-mdpi/ic_goto_quran.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_link.png b/app/src/main/res/drawable-mdpi/ic_link.png new file mode 100644 index 0000000000..6960502e9b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_new.png b/app/src/main/res/drawable-mdpi/ic_new.png new file mode 100644 index 0000000000..977dd3427a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_new.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_next.png b/app/src/main/res/drawable-mdpi/ic_next.png new file mode 100644 index 0000000000..936cdd77d5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_next.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_night_mode.png b/app/src/main/res/drawable-mdpi/ic_night_mode.png new file mode 100644 index 0000000000..3103121d09 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_night_mode.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_not_favorite.png b/app/src/main/res/drawable-mdpi/ic_not_favorite.png new file mode 100644 index 0000000000..ec69d41f25 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_not_favorite.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pause.png b/app/src/main/res/drawable-mdpi/ic_pause.png new file mode 100644 index 0000000000..026f38854c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_play.png b/app/src/main/res/drawable-mdpi/ic_play.png new file mode 100644 index 0000000000..8d1e433a56 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_previous.png b/app/src/main/res/drawable-mdpi/ic_previous.png new file mode 100644 index 0000000000..97970e08f1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_previous.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_repeat.png b/app/src/main/res/drawable-mdpi/ic_repeat.png new file mode 100644 index 0000000000..8a2b641ca7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_repeat.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_share.png b/app/src/main/res/drawable-mdpi/ic_share.png new file mode 100644 index 0000000000..4d01972233 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_sort.png b/app/src/main/res/drawable-mdpi/ic_sort.png new file mode 100644 index 0000000000..89cc743f49 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stop.png b/app/src/main/res/drawable-mdpi/ic_stop.png new file mode 100644 index 0000000000..d96a73ced1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_tag.png b/app/src/main/res/drawable-mdpi/ic_tag.png new file mode 100644 index 0000000000..b1573698de Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_tag.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_translation.png b/app/src/main/res/drawable-mdpi/ic_translation.png new file mode 100644 index 0000000000..fec03cbe95 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_translation.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_translation_left.png b/app/src/main/res/drawable-mdpi/ic_translation_left.png new file mode 100644 index 0000000000..5513b05aaf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_translation_left.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_translation_right.png b/app/src/main/res/drawable-mdpi/ic_translation_right.png new file mode 100644 index 0000000000..3862a8cd35 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_translation_right.png differ diff --git a/res/drawable/remove_bookmark.png b/app/src/main/res/drawable-nodpi/border_left.png similarity index 73% rename from res/drawable/remove_bookmark.png rename to app/src/main/res/drawable-nodpi/border_left.png index 6d73fd2b70..8e87afb111 100644 Binary files a/res/drawable/remove_bookmark.png and b/app/src/main/res/drawable-nodpi/border_left.png differ diff --git a/app/src/main/res/drawable-nodpi/border_right.png b/app/src/main/res/drawable-nodpi/border_right.png new file mode 100644 index 0000000000..c625ffd6f0 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/border_right.png differ diff --git a/app/src/main/res/drawable-nodpi/night_left_border.png b/app/src/main/res/drawable-nodpi/night_left_border.png new file mode 100644 index 0000000000..0be21e255f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/night_left_border.png differ diff --git a/app/src/main/res/drawable-nodpi/night_right_border.png b/app/src/main/res/drawable-nodpi/night_right_border.png new file mode 100644 index 0000000000..d0bb70fd97 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/night_right_border.png differ diff --git a/app/src/main/res/drawable-v21/header_ripple.xml b/app/src/main/res/drawable-v21/header_ripple.xml new file mode 100644 index 0000000000..4a64d09e29 --- /dev/null +++ b/app/src/main/res/drawable-v21/header_ripple.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/list_header_background.xml b/app/src/main/res/drawable-v21/list_header_background.xml new file mode 100644 index 0000000000..a9c7a6b660 --- /dev/null +++ b/app/src/main/res/drawable-v21/list_header_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/quran_row_background.xml b/app/src/main/res/drawable-v21/quran_row_background.xml new file mode 100644 index 0000000000..f2318a4c47 --- /dev/null +++ b/app/src/main/res/drawable-v21/quran_row_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/quran_row_ripple.xml b/app/src/main/res/drawable-v21/quran_row_ripple.xml new file mode 100644 index 0000000000..84230751e0 --- /dev/null +++ b/app/src/main/res/drawable-v21/quran_row_ripple.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/toolbar_button.xml b/app/src/main/res/drawable-v21/toolbar_button.xml new file mode 100644 index 0000000000..84230751e0 --- /dev/null +++ b/app/src/main/res/drawable-v21/toolbar_button.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/bookmark_currentpage.png b/app/src/main/res/drawable-xhdpi/bookmark_currentpage.png new file mode 100644 index 0000000000..3cd20fbb9d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bookmark_currentpage.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_ab_search.png b/app/src/main/res/drawable-xhdpi/ic_ab_search.png new file mode 100644 index 0000000000..043759acd4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_ab_search.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_accept.png b/app/src/main/res/drawable-xhdpi/ic_accept.png new file mode 100644 index 0000000000..e5024472a2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_accept.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_expand.png b/app/src/main/res/drawable-xhdpi/ic_action_expand.png new file mode 100644 index 0000000000..c0e60c223e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_expand.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_settings.png b/app/src/main/res/drawable-xhdpi/ic_action_settings.png new file mode 100644 index 0000000000..12e5d100dd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_cancel.png b/app/src/main/res/drawable-xhdpi/ic_cancel.png new file mode 100644 index 0000000000..76e07f0970 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_copy.png b/app/src/main/res/drawable-xhdpi/ic_copy.png new file mode 100644 index 0000000000..04a0cc94b2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_copy.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_day_mode.png b/app/src/main/res/drawable-xhdpi/ic_day_mode.png new file mode 100644 index 0000000000..757c10845a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_day_mode.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_download.png b/app/src/main/res/drawable-xhdpi/ic_download.png new file mode 100644 index 0000000000..990dfb82b3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_download.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_edit.png b/app/src/main/res/drawable-xhdpi/ic_edit.png new file mode 100644 index 0000000000..7f0ea51bf6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_favorite.png b/app/src/main/res/drawable-xhdpi/ic_favorite.png new file mode 100644 index 0000000000..c569832890 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_favorite.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_goto_quran.png b/app/src/main/res/drawable-xhdpi/ic_goto_quran.png new file mode 100644 index 0000000000..856e475d2e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_goto_quran.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_link.png b/app/src/main/res/drawable-xhdpi/ic_link.png new file mode 100644 index 0000000000..984b572ac4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_new.png b/app/src/main/res/drawable-xhdpi/ic_new.png new file mode 100644 index 0000000000..67042105d2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_new.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_next.png b/app/src/main/res/drawable-xhdpi/ic_next.png new file mode 100644 index 0000000000..f282b92457 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_next.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_night_mode.png b/app/src/main/res/drawable-xhdpi/ic_night_mode.png new file mode 100644 index 0000000000..5c61118a03 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_night_mode.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_not_favorite.png b/app/src/main/res/drawable-xhdpi/ic_not_favorite.png new file mode 100644 index 0000000000..f9f17801b7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_not_favorite.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000000..4886305516 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pause.png b/app/src/main/res/drawable-xhdpi/ic_pause.png new file mode 100644 index 0000000000..14b6d17d4a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_play.png b/app/src/main/res/drawable-xhdpi/ic_play.png new file mode 100644 index 0000000000..a55d19922f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_previous.png b/app/src/main/res/drawable-xhdpi/ic_previous.png new file mode 100644 index 0000000000..2522877dfa Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_previous.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_repeat.png b/app/src/main/res/drawable-xhdpi/ic_repeat.png new file mode 100644 index 0000000000..729220066a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_repeat.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_share.png b/app/src/main/res/drawable-xhdpi/ic_share.png new file mode 100644 index 0000000000..dd536bca2d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_shortcut_last_page.png b/app/src/main/res/drawable-xhdpi/ic_shortcut_last_page.png new file mode 100644 index 0000000000..fe16168967 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_shortcut_last_page.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_sort.png b/app/src/main/res/drawable-xhdpi/ic_sort.png new file mode 100644 index 0000000000..89008555a6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stop.png b/app/src/main/res/drawable-xhdpi/ic_stop.png new file mode 100644 index 0000000000..9a6e57b632 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_tag.png b/app/src/main/res/drawable-xhdpi/ic_tag.png new file mode 100644 index 0000000000..2fed14ed4c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_tag.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_translation.png b/app/src/main/res/drawable-xhdpi/ic_translation.png new file mode 100644 index 0000000000..b695749017 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_translation.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_translation_left.png b/app/src/main/res/drawable-xhdpi/ic_translation_left.png new file mode 100644 index 0000000000..815b155c57 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_translation_left.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_translation_right.png b/app/src/main/res/drawable-xhdpi/ic_translation_right.png new file mode 100644 index 0000000000..9afecd275b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_translation_right.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bookmark_currentpage.png b/app/src/main/res/drawable-xxhdpi/bookmark_currentpage.png new file mode 100644 index 0000000000..9871a49120 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bookmark_currentpage.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ab_search.png b/app/src/main/res/drawable-xxhdpi/ic_ab_search.png new file mode 100644 index 0000000000..0bbeab1501 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ab_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_accept.png b/app/src/main/res/drawable-xxhdpi/ic_accept.png new file mode 100644 index 0000000000..6e03d54cf4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_accept.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_expand.png b/app/src/main/res/drawable-xxhdpi/ic_action_expand.png new file mode 100644 index 0000000000..f00aa8bd4e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_expand.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_settings.png b/app/src/main/res/drawable-xxhdpi/ic_action_settings.png new file mode 100644 index 0000000000..6bb8f6e080 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_cancel.png b/app/src/main/res/drawable-xxhdpi/ic_cancel.png new file mode 100644 index 0000000000..f54f4f9d11 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_copy.png b/app/src/main/res/drawable-xxhdpi/ic_copy.png new file mode 100644 index 0000000000..5fc17a4d13 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_copy.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_day_mode.png b/app/src/main/res/drawable-xxhdpi/ic_day_mode.png new file mode 100644 index 0000000000..4618e5ce31 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_day_mode.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_download.png b/app/src/main/res/drawable-xxhdpi/ic_download.png new file mode 100644 index 0000000000..95502de3f1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_edit.png b/app/src/main/res/drawable-xxhdpi/ic_edit.png new file mode 100644 index 0000000000..34ec7092f1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_favorite.png b/app/src/main/res/drawable-xxhdpi/ic_favorite.png new file mode 100644 index 0000000000..abda33a908 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_favorite.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_link.png b/app/src/main/res/drawable-xxhdpi/ic_link.png new file mode 100644 index 0000000000..8e96c356b9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_new.png b/app/src/main/res/drawable-xxhdpi/ic_new.png new file mode 100644 index 0000000000..72cedcad4f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_new.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_next.png b/app/src/main/res/drawable-xxhdpi/ic_next.png new file mode 100644 index 0000000000..4fe60888b1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_next.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_night_mode.png b/app/src/main/res/drawable-xxhdpi/ic_night_mode.png new file mode 100644 index 0000000000..1e00af657e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_night_mode.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_not_favorite.png b/app/src/main/res/drawable-xxhdpi/ic_not_favorite.png new file mode 100644 index 0000000000..fedb1471da Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_not_favorite.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000000..5dd60b0db6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pause.png b/app/src/main/res/drawable-xxhdpi/ic_pause.png new file mode 100644 index 0000000000..72dfa9fa6c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_play.png b/app/src/main/res/drawable-xxhdpi/ic_play.png new file mode 100644 index 0000000000..043acd808e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_previous.png b/app/src/main/res/drawable-xxhdpi/ic_previous.png new file mode 100644 index 0000000000..2c9310af90 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_previous.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_repeat.png b/app/src/main/res/drawable-xxhdpi/ic_repeat.png new file mode 100644 index 0000000000..63f8de50ff Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_repeat.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share.png b/app/src/main/res/drawable-xxhdpi/ic_share.png new file mode 100644 index 0000000000..9963c6a056 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_shortcut_last_page.png b/app/src/main/res/drawable-xxhdpi/ic_shortcut_last_page.png new file mode 100644 index 0000000000..af095b331a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_shortcut_last_page.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sort.png b/app/src/main/res/drawable-xxhdpi/ic_sort.png new file mode 100644 index 0000000000..d1127ebc08 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stop.png b/app/src/main/res/drawable-xxhdpi/ic_stop.png new file mode 100644 index 0000000000..bfa39f3e4e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tag.png b/app/src/main/res/drawable-xxhdpi/ic_tag.png new file mode 100644 index 0000000000..1b4c1b7875 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tag.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_translation.png b/app/src/main/res/drawable-xxhdpi/ic_translation.png new file mode 100644 index 0000000000..fd0acd20d2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_translation.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_translation_left.png b/app/src/main/res/drawable-xxhdpi/ic_translation_left.png new file mode 100644 index 0000000000..69ac65e973 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_translation_left.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_translation_right.png b/app/src/main/res/drawable-xxhdpi/ic_translation_right.png new file mode 100644 index 0000000000..89ba1a2e14 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_translation_right.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/bookmark_currentpage.png b/app/src/main/res/drawable-xxxhdpi/bookmark_currentpage.png new file mode 100644 index 0000000000..b465fca588 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/bookmark_currentpage.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_ab_search.png b/app/src/main/res/drawable-xxxhdpi/ic_ab_search.png new file mode 100644 index 0000000000..70c21baf77 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_ab_search.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_accept.png b/app/src/main/res/drawable-xxxhdpi/ic_accept.png new file mode 100644 index 0000000000..87892840e6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_accept.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_action_expand.png b/app/src/main/res/drawable-xxxhdpi/ic_action_expand.png new file mode 100644 index 0000000000..3bc2f7d541 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_action_expand.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_action_settings.png b/app/src/main/res/drawable-xxxhdpi/ic_action_settings.png new file mode 100644 index 0000000000..97e9ca945c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_cancel.png b/app/src/main/res/drawable-xxxhdpi/ic_cancel.png new file mode 100644 index 0000000000..7b2a480a02 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_cancel.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_copy.png b/app/src/main/res/drawable-xxxhdpi/ic_copy.png new file mode 100644 index 0000000000..557c64f7aa Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_copy.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_day_mode.png b/app/src/main/res/drawable-xxxhdpi/ic_day_mode.png new file mode 100644 index 0000000000..021d635a01 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_day_mode.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_download.png b/app/src/main/res/drawable-xxxhdpi/ic_download.png new file mode 100644 index 0000000000..a57e72f7dc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_download.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_edit.png b/app/src/main/res/drawable-xxxhdpi/ic_edit.png new file mode 100644 index 0000000000..9380370f48 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_edit.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_favorite.png b/app/src/main/res/drawable-xxxhdpi/ic_favorite.png new file mode 100644 index 0000000000..325c7b2f56 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_favorite.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_link.png b/app/src/main/res/drawable-xxxhdpi/ic_link.png new file mode 100644 index 0000000000..df2faf36d0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_link.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_new.png b/app/src/main/res/drawable-xxxhdpi/ic_new.png new file mode 100644 index 0000000000..2bef059583 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_new.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_next.png b/app/src/main/res/drawable-xxxhdpi/ic_next.png new file mode 100644 index 0000000000..126b88c3d6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_next.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_night_mode.png b/app/src/main/res/drawable-xxxhdpi/ic_night_mode.png new file mode 100644 index 0000000000..85841c0439 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_night_mode.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_not_favorite.png b/app/src/main/res/drawable-xxxhdpi/ic_not_favorite.png new file mode 100644 index 0000000000..c987a70597 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_not_favorite.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_pause.png b/app/src/main/res/drawable-xxxhdpi/ic_pause.png new file mode 100644 index 0000000000..dd4bfdba54 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_play.png b/app/src/main/res/drawable-xxxhdpi/ic_play.png new file mode 100644 index 0000000000..7cc0084756 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_previous.png b/app/src/main/res/drawable-xxxhdpi/ic_previous.png new file mode 100644 index 0000000000..fcf2d0cfea Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_previous.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_repeat.png b/app/src/main/res/drawable-xxxhdpi/ic_repeat.png new file mode 100644 index 0000000000..f3c284330e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_repeat.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_share.png b/app/src/main/res/drawable-xxxhdpi/ic_share.png new file mode 100644 index 0000000000..bb521c141b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_share.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shortcut_last_page.png b/app/src/main/res/drawable-xxxhdpi/ic_shortcut_last_page.png new file mode 100644 index 0000000000..da1d0e2030 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_shortcut_last_page.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_sort.png b/app/src/main/res/drawable-xxxhdpi/ic_sort.png new file mode 100644 index 0000000000..cb94a9f675 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_sort.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stop.png b/app/src/main/res/drawable-xxxhdpi/ic_stop.png new file mode 100644 index 0000000000..b31c7ae9a7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_tag.png b/app/src/main/res/drawable-xxxhdpi/ic_tag.png new file mode 100644 index 0000000000..a645d990dc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_tag.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_translation.png b/app/src/main/res/drawable-xxxhdpi/ic_translation.png new file mode 100644 index 0000000000..6d1f67b414 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_translation.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_translation_left.png b/app/src/main/res/drawable-xxxhdpi/ic_translation_left.png new file mode 100644 index 0000000000..8a4a7c15ca Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_translation_left.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_translation_right.png b/app/src/main/res/drawable-xxxhdpi/ic_translation_right.png new file mode 100644 index 0000000000..060b7ed5ba Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_translation_right.png differ diff --git a/app/src/main/res/drawable/download_button_circle.xml b/app/src/main/res/drawable/download_button_circle.xml new file mode 100644 index 0000000000..93e9274f8f --- /dev/null +++ b/app/src/main/res/drawable/download_button_circle.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_header_background.xml b/app/src/main/res/drawable/list_header_background.xml new file mode 100644 index 0000000000..52f6f4eade --- /dev/null +++ b/app/src/main/res/drawable/list_header_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/quran_row_background.xml b/app/src/main/res/drawable/quran_row_background.xml new file mode 100644 index 0000000000..068cc5cf9d --- /dev/null +++ b/app/src/main/res/drawable/quran_row_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sliding_panel_above_shadow.xml b/app/src/main/res/drawable/sliding_panel_above_shadow.xml new file mode 100644 index 0000000000..cf890b9b67 --- /dev/null +++ b/app/src/main/res/drawable/sliding_panel_above_shadow.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sliding_panel_below_shadow.xml b/app/src/main/res/drawable/sliding_panel_below_shadow.xml new file mode 100644 index 0000000000..3255f0e8fe --- /dev/null +++ b/app/src/main/res/drawable/sliding_panel_below_shadow.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/toolbar_button.xml b/app/src/main/res/drawable/toolbar_button.xml new file mode 100644 index 0000000000..c750b67261 --- /dev/null +++ b/app/src/main/res/drawable/toolbar_button.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-ar-land-v17/audio_panel.xml b/app/src/main/res/layout-ar-land-v17/audio_panel.xml new file mode 100644 index 0000000000..ae125e63ff --- /dev/null +++ b/app/src/main/res/layout-ar-land-v17/audio_panel.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +