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..404591b693 100644
--- a/README.md
+++ b/README.md
@@ -1,73 +1,45 @@
-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.
+
+Though very rarely, we do push beta versions in Play Store for early testing. If you would like to participate in beta program, please join our [Quran for Android](https://plus.google.com/communities/100110719319613677297) community in Google+.
+
+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..b8ee860f80
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,145 @@
+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 2751
+ versionName "2.7.5-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 1081
+ versionName "1.0.8-p1"
+ }
+
+ naskh {
+ applicationId "com.quran.labs.androidquran.naskh"
+ versionCode 1081
+ versionName "1.0.8-p1"
+ }
+
+ shemerly {
+ applicationId "com.quran.labs.androidquran.shemerly"
+ versionCode 1081
+ versionName "1.0.8-p1"
+ }
+
+ warsh {
+ applicationId "com.quran.labs.androidquran.warsh"
+ versionCode 1081
+ versionName "1.0.8-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
+ ext.alwaysUpdateBuildId = 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.3.1'
+ espressoVersion = '2.2.2'
+ okhttpVersion = '3.8.0'
+}
+
+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.1.0'
+ compile 'io.reactivex.rxjava2:rxandroid:2.0.1'
+ annotationProcessor 'com.google.dagger:dagger-compiler:2.9'
+ compile 'com.google.dagger:dagger:2.9'
+ compile "com.squareup.okhttp3:okhttp:${okhttpVersion}"
+ compile 'com.squareup.moshi:moshi:1.4.0'
+ compile 'com.jakewharton.timber:timber:4.5.1'
+ compile 'com.jakewharton:butterknife:8.5.1'
+ annotationProcessor 'com.jakewharton:butterknife-compiler:8.5.1'
+ debugCompile 'com.facebook.stetho:stetho:1.5.0'
+ debugCompile 'com.facebook.stetho:stetho-okhttp3:1.5.0'
+ debugCompile 'com.squareup.leakcanary:leakcanary-android:1.5.1'
+ 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:${okhttpVersion}"
+ 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.8@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..2f24a87a3b
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/QuranDataActivity.java
@@ -0,0 +1,533 @@
+package com.quran.labs.androidquran;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+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 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 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 isPaused = false;
+ private AsyncTask checkPagesTask;
+ private QuranSettings quranSettings;
+ private AlertDialog errorDialog = null;
+ private AlertDialog promptForDownloadDialog = null;
+ private AlertDialog permissionsDialog;
+ private DefaultDownloadReceiver downloadReceiver = null;
+ private boolean needPortraitImages = false;
+ private boolean needLandscapeImages = false;
+ private boolean taskIsRunning;
+ private String patchUrl;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ QuranScreenInfo.getOrMakeInstance(this);
+ quranSettings = QuranSettings.getInstance(this);
+ quranSettings.upgradePreferences();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ isPaused = false;
+ downloadReceiver = new DefaultDownloadReceiver(this,
+ QuranDownloadService.DOWNLOAD_TYPE_PAGES);
+ downloadReceiver.setCanCancelDownload(true);
+ String action = QuranDownloadNotifier.ProgressIntent.INTENT_NAME;
+ LocalBroadcastManager.getInstance(this).registerReceiver(
+ downloadReceiver,
+ new IntentFilter(action));
+ downloadReceiver.setListener(this);
+ checkPermissions();
+ }
+
+ @Override
+ protected void onPause() {
+ isPaused = true;
+ if (downloadReceiver != null) {
+ downloadReceiver.setListener(null);
+ LocalBroadcastManager.getInstance(this).
+ unregisterReceiver(downloadReceiver);
+ downloadReceiver = null;
+ }
+
+ if (promptForDownloadDialog != null) {
+ promptForDownloadDialog.dismiss();
+ promptForDownloadDialog = null;
+ }
+
+ if (errorDialog != null) {
+ errorDialog.dismiss();
+ errorDialog = null;
+ }
+
+ super.onPause();
+ }
+
+ private void checkPermissions() {
+ final String path = quranSettings.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
+ permissionsDialog = new AlertDialog.Builder(this)
+ .setMessage(R.string.storage_permission_rationale)
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ dialog.dismiss();
+ permissionsDialog = null;
+
+ Answers.getInstance().logCustom(
+ new CustomEvent("storagePermissionRationaleAccepted"));
+ // request permissions
+ requestExternalSdcardPermission();
+ })
+ .setNegativeButton(android.R.string.no, (dialog, which) -> {
+ // dismiss the dialog
+ dialog.dismiss();
+ permissionsDialog = null;
+
+ Answers.getInstance().logCustom(
+ new CustomEvent("storagePermissionRationaleDenied"));
+ // fall back if we can
+ if (fallbackFile != null) {
+ quranSettings.setAppCustomLocation(fallbackFile.getAbsolutePath());
+ checkPages();
+ } else {
+ // set to null so we can try again next launch
+ quranSettings.setAppCustomLocation(null);
+ runListView();
+ }
+ })
+ .create();
+ permissionsDialog.show();
+ } else {
+ // fall back if we can
+ if (fallbackFile != null) {
+ quranSettings.setAppCustomLocation(fallbackFile.getAbsolutePath());
+ checkPages();
+ } else {
+ // set to null so we can try again next launch
+ quranSettings.setAppCustomLocation(null);
+ runListView();
+ }
+ }
+ } else {
+ checkPages();
+ }
+ }
+
+ private void checkPages() {
+ if (taskIsRunning) {
+ return;
+ }
+
+ taskIsRunning = true;
+
+ // check whether or not we need to download
+ checkPagesTask = new CheckPagesAsyncTask(this);
+ checkPagesTask.execute();
+ }
+
+ private void requestExternalSdcardPermission() {
+ ActivityCompat.requestPermissions(this,
+ new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE },
+ REQUEST_WRITE_TO_SDCARD_PERMISSIONS);
+ quranSettings.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) {
+ quranSettings.setAppCustomLocation(fallbackFile.getAbsolutePath());
+ checkPages();
+ } else {
+ // set to null so we can try again next launch
+ quranSettings.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() {
+ quranSettings.removeShouldFetchPages();
+ runListView();
+ }
+
+ @Override
+ public void handleDownloadFailure(int errId) {
+ if (errorDialog != null && errorDialog.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,
+ (dialog, id) -> {
+ dialog.dismiss();
+ errorDialog = null;
+ removeErrorPreferences();
+ downloadQuranImages(true);
+ });
+
+ builder.setNegativeButton(R.string.download_cancel,
+ (dialog, which) -> {
+ dialog.dismiss();
+ errorDialog = null;
+ removeErrorPreferences();
+ quranSettings.setShouldFetchPages(false);
+ runListView();
+ });
+
+ errorDialog = builder.create();
+ errorDialog.show();
+ }
+
+ private void removeErrorPreferences() {
+ quranSettings.clearLastDownloadError();
+ }
+
+ class CheckPagesAsyncTask extends AsyncTask {
+
+ private final Context appContext;
+ private String patchParam;
+ private boolean storageNotAvailable;
+
+ public CheckPagesAsyncTask(Context context) {
+ appContext = context.getApplicationContext();
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ final String baseDir = QuranFileUtils.getQuranBaseDirectory(appContext);
+ if (baseDir == null) {
+ storageNotAvailable = true;
+ return false;
+ }
+
+ final QuranScreenInfo qsi = QuranScreenInfo.getInstance();
+ if (!quranSettings.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(appContext);
+ if (fallback != null) {
+ quranSettings.setDefaultImagesDirectory(fallback);
+ qsi.setOverrideParam(fallback);
+ }
+ }
+
+ final String width = qsi.getWidthParam();
+ if (qsi.isDualPageMode(appContext)) {
+ final String tabletWidth = qsi.getTabletWidthParam();
+ boolean haveLandscape = QuranFileUtils.haveAllImages(appContext, tabletWidth);
+ boolean havePortrait = QuranFileUtils.haveAllImages(appContext, width);
+ needPortraitImages = !havePortrait;
+ needLandscapeImages = !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(appContext, width, LATEST_IMAGE_VERSION) ||
+ !QuranFileUtils.isVersion(appContext, tabletWidth, LATEST_IMAGE_VERSION)) {
+ if (!width.equals(tabletWidth)) {
+ patchParam = width + tabletWidth;
+ } else {
+ patchParam = width;
+ }
+ }
+ }
+ return haveLandscape && havePortrait;
+ } else {
+ boolean haveAll = QuranFileUtils.haveAllImages(appContext,
+ QuranScreenInfo.getInstance().getWidthParam());
+ Timber.d("checkPages: have all images: %s", haveAll ? "yes" : "no");
+ needPortraitImages = !haveAll;
+ needLandscapeImages = false;
+ if (haveAll && !QuranFileUtils.isVersion(appContext, width, LATEST_IMAGE_VERSION)) {
+ patchParam = width;
+ }
+ return haveAll;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(@NonNull Boolean result) {
+ checkPagesTask = null;
+ patchUrl = null;
+ taskIsRunning = false;
+
+ if (isPaused) {
+ return;
+ }
+
+ if (!result) {
+ // we need to download pages in this case
+
+ // if we downloaded pages once before
+ if (quranSettings.didDownloadPages()) {
+ // log an event to Answers - this should help figure out why people are complaining that
+ // they are always prompted to re-download images, even after they did.
+ Answers.getInstance()
+ .logCustom(new CustomEvent("imagesDisappeared")
+ .putCustomAttribute("storagePath", quranSettings.getAppCustomLocation()));
+ quranSettings.setDownloadedPages(false);
+ }
+
+ if (storageNotAvailable) {
+ // no storage mounted, nothing we can do...
+ runListView();
+ return;
+ }
+
+ String lastErrorItem = quranSettings.getLastDownloadItemWithError();
+ Timber.d("checkPages: need to download pages... lastError: %s", lastErrorItem);
+ if (PAGES_DOWNLOAD_KEY.equals(lastErrorItem)) {
+ int lastError = quranSettings.getLastDownloadErrorCode();
+ int errorId = ServiceIntentHelper
+ .getErrorResourceFromErrorCode(lastError, false);
+ showFatalErrorDialog(errorId);
+ } else if (quranSettings.shouldFetchPages()) {
+ downloadQuranImages(false);
+ } else {
+ promptForDownload();
+ }
+ } else {
+ quranSettings.setDownloadedPages(true);
+ if (!TextUtils.isEmpty(patchParam)) {
+ Timber.d("checkPages: have pages, but need patch %s", patchParam);
+ patchUrl = QuranFileUtils.getPatchFileUrl(patchParam, 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 (downloadReceiver != null &&
+ downloadReceiver.didReceiveBroadcast() && !force) {
+ return;
+ }
+ if (isPaused) {
+ return;
+ }
+
+ QuranScreenInfo qsi = QuranScreenInfo.getInstance();
+
+ String url;
+ if (needPortraitImages && !needLandscapeImages) {
+ // phone (and tablet when upgrading on some devices, ex n10)
+ url = QuranFileUtils.getZipFileUrl();
+ } else if (needLandscapeImages && !needPortraitImages) {
+ // 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(patchUrl)) {
+ url = patchUrl;
+ }
+
+ 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().isDualPageMode(this) &&
+ (needPortraitImages != needLandscapeImages)) {
+ message = R.string.downloadTabletPrompt;
+ }
+
+ if (!TextUtils.isEmpty(patchUrl)) {
+ // 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,
+ (dialog1, id) -> {
+ dialog1.dismiss();
+ promptForDownloadDialog = null;
+ quranSettings.setShouldFetchPages(true);
+ downloadQuranImages(true);
+ });
+
+ dialog.setNegativeButton(R.string.downloadPrompt_no,
+ (dialog12, id) -> {
+ dialog12.dismiss();
+ promptForDownloadDialog = null;
+ runListView();
+ });
+
+ promptForDownloadDialog = dialog.create();
+ promptForDownloadDialog.setTitle(R.string.downloadPrompt_title);
+ promptForDownloadDialog.show();
+ }
+
+ protected void runListView() {
+ Intent i = new Intent(this, QuranActivity.class);
+ i.putExtra(QuranActivity.EXTRA_SHOW_TRANSLATION_UPGRADE, quranSettings.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..0caf6cfaae
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/SearchActivity.java
@@ -0,0 +1,306 @@
+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.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.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.CursorAdapter;
+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.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;
+
+ @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);
+ buttonGetTranslations.setOnClickListener(v -> {
+ Intent intent;
+ if (downloadArabicSearchDb) {
+ downloadArabicSearchDb();
+ return;
+ } else {
+ intent = new Intent(getApplicationContext(), TranslationManagerActivity.class);
+ }
+ startActivity(intent);
+ finish();
+ });
+ handleIntent(getIntent());
+ }
+
+ @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;
+ 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) {
+ 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((parent, view, position, 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 static class ResultAdapter extends CursorAdapter {
+ private Context context;
+ private LayoutInflater inflater;
+
+ ResultAdapter(Context context, Cursor cursor) {
+ super(context, cursor, 0);
+ inflater = LayoutInflater.from(context);
+ this.context = context;
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ final View view = inflater.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(this.context, sura, false);
+ holder.text.setText(Html.fromHtml(text));
+ holder.metadata.setText(this.context.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..9362fb5f68
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/common/LocalTranslation.java
@@ -0,0 +1,42 @@
+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 String languageCode;
+ public final int version;
+
+ public LocalTranslation(int id,
+ String filename,
+ String name,
+ String translator,
+ String translatorForeign,
+ String url,
+ String languageCode,
+ int version) {
+ this.id = id;
+ this.filename = filename;
+ this.name = name;
+ this.translator = translator;
+ this.translatorForeign = translatorForeign;
+ this.url = url;
+ this.languageCode = languageCode;
+ 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..1537b8938a
--- /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;
+
+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;
+ public final String languageCode;
+ public final int minimumVersion;
+ public final int currentVersion;
+ public final String fileName;
+ public final String translatorNameLocalized;
+
+ public Translation(int id, int minimumVersion, int currentVersion, String displayName,
+ String downloadType, String fileName, String fileUrl, String saveTo,
+ String languageCode, 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.languageCode = languageCode;
+ 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..68a11d8c41
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/data/BaseQuranData.java
@@ -0,0 +1,252 @@
+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..043c05f1e1
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/data/Constants.java
@@ -0,0 +1,80 @@
+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_VOICE_LANGUAGE = "preferredVoiceLanguage";
+ 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_DUAL_PAGE_ENABLED = "useDualPageMode";
+ 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";
+ public static final String PREF_DID_DOWNLOAD_PAGES = "didDownloadPages";
+}
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..1dba085dc9
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java
@@ -0,0 +1,261 @@
+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 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.QuranUtils;
+
+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 String VERSES_MIME_TYPE =
+ ContentResolver.CURSOR_DIR_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 SEARCH_SUGGEST = 1;
+ private static final UriMatcher uriMatcher = buildUriMatcher();
+
+ private boolean didInject;
+ @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, 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);
+ }
+
+ return search(selectionArgs[0]);
+ }
+ default: {
+ throw new IllegalArgumentException("Unknown Uri: " + uri);
+ }
+ }
+ }
+
+ private Cursor search(String query) {
+ return search(query, getAvailableTranslations());
+ }
+
+ private List getAvailableTranslations() {
+ return translationsDBAdapter.getTranslations();
+ }
+
+ private Cursor getSuggestions(String query) {
+ if (query.length() < 3) {
+ return null;
+ }
+
+ final boolean queryIsArabic = QuranUtils.doesStringContainArabic(query);
+ final boolean haveArabic = queryIsArabic &&
+ QuranFileUtils.hasTranslation(getContext(), QURAN_ARABIC_DATABASE);
+
+ List translations = getAvailableTranslations();
+ if (translations.size() == 0 && (queryIsArabic && !haveArabic)) {
+ return null;
+ }
+
+ int total = translations.size();
+ int start = haveArabic ? -1 : 0;
+
+ 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 (int i = start; i < total; i++) {
+ if (gotResults) {
+ continue;
+ }
+
+ String database;
+ if (i < 0) {
+ database = QURAN_ARABIC_DATABASE;
+ } else {
+ LocalTranslation translation = translations.get(i);
+ // skip non-arabic databases if the query is in arabic
+ if (queryIsArabic &&
+ translation.languageCode != null &&
+ !"ar".equals(translation.languageCode)) {
+ continue;
+ } else if (!queryIsArabic && "ar".equals(translation.languageCode)) {
+ // skip arabic databases when the query isn't arabic
+ continue;
+ }
+ database = translation.filename;
+ }
+
+ Cursor suggestions = null;
+ try {
+ suggestions = search(query, database, 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, List translations) {
+ Timber.d("query: %s", query);
+
+ final Context context = getContext();
+ final boolean queryIsArabic = QuranUtils.doesStringContainArabic(query);
+ final boolean haveArabic = queryIsArabic &&
+ QuranFileUtils.hasTranslation(context, QURAN_ARABIC_DATABASE);
+ if (translations.size() == 0 || (queryIsArabic && !haveArabic)) {
+ return null;
+ }
+
+ int start = haveArabic ? -1 : 0;
+ int total = translations.size();
+
+ for (int i = start; i < total; i++) {
+ String databaseName;
+ if (i < 0) {
+ databaseName = QURAN_ARABIC_DATABASE;
+ } else {
+ LocalTranslation translation = translations.get(i);
+ // skip non-arabic databases if the query is in arabic
+ if (queryIsArabic &&
+ translation.languageCode != null &&
+ !"ar".equals(translation.languageCode)) {
+ continue;
+ } else if (!queryIsArabic && "ar".equals(translation.languageCode)) {
+ // skip arabic databases when the query isn't arabic
+ continue;
+ }
+ databaseName = translation.filename;
+ }
+
+ Cursor cursor = search(query, databaseName, true);
+ if (cursor != null && cursor.getCount() > 0) {
+ return cursor;
+ }
+ }
+ return null;
+ }
+
+ private Cursor search(String query, String databaseName, boolean wantSnippets) {
+ final DatabaseHandler handler = DatabaseHandler.getDatabaseHandler(getContext(), databaseName);
+ return handler.search(query, wantSnippets);
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ switch (uriMatcher.match(uri)) {
+ case SEARCH_VERSES: {
+ return VERSES_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..f76a749e6a
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.java
@@ -0,0 +1,293 @@
+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.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 search(String q, String table, boolean withSnippets) {
+ if (!validDatabase()) {
+ return null;
+ }
+
+ final String limit = withSnippets ? "" : "LIMIT 25";
+
+ 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;
+ 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..85a0a557f0
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBAdapter.java
@@ -0,0 +1,126 @@
+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.annotation.WorkerThread;
+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;
+ }
+
+ @WorkerThread
+ @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);
+ String languageCode = cursor.getString(6);
+ int version = cursor.getInt(7);
+
+ if (QuranFileUtils.hasTranslation(context, filename)) {
+ items.add(new LocalTranslation(id, filename, name, translator,
+ translatorForeign, url, languageCode, 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.LANGUAGE_CODE, item.translation.languageCode);
+ 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..70fa86ec28
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/database/TranslationsDBHelper.java
@@ -0,0 +1,78 @@
+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 = 3;
+ 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.LANGUAGE_CODE + " 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 < 3) {
+ // 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 LANGUAGE_CODE = "languageCode";
+ 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