diff --git a/README.md b/README.md index 452ad7b..88066be 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ Licences are in [`licenses/`](licenses/). ## Themes, size, language -Five themes, persisted: System, Light, Dark, Sepia and Sepia night. The two sepia schemes are +Six themes, persisted: System, Light, Dark, Sepia, Sepia night, and Black for OLED panels, +where an unlit pixel costs no power at all. The two sepia schemes are warm paper tones for long sessions; the dark one has no blue cast. Text size runs 14–40 sp with a live preview in the settings sheet. @@ -135,15 +136,13 @@ server supports **AGP 9.4.1** — it is new, and that is the most likely thing t ## Licensing -The app code is MIT (see [`LICENSE`](LICENSE)). What it builds on: +The app code is MIT (see [`LICENSE`](LICENSE)). Every other component — the poems, the three +bundled fonts, and each library — is credited with its terms in +[`licenses/README.md`](licenses/README.md), and the full licence texts ship inside the APK, +readable at **Reading settings → About & licences**. -- **The poetry** is classical Persian verse, long out of copyright. -- **The data set** ([`ganjoor/ganjoor-data`](https://github.com/ganjoor/ganjoor-data)) carries no - licence file at all. Worth asking upstream to add an explicit one. -- **[GanjoorService](https://github.com/ganjoor/GanjoorService)**, Ganjoor's own backend and site, - is GPL-3.0. This app uses none of its code — only data over HTTPS — so it is not a derivative - work of it. Nothing in Ganjoor's repositories restricts AI-assisted use. -- **The icon** is original: an eight-point shamsa, the star that tiles Persian architecture. - Ganjoor's own app icons are unlicensed and are their mark, so they are not used here. +The two things worth knowing up front: the **data set** carries no licence file, and +**GanjoorService** is GPL-3.0 but none of its code is used here — only data over HTTPS — so this +app is not a derivative work of it. Not affiliated with or endorsed by Ganjoor. diff --git a/app/src/main/assets/licenses/apache-2.0.txt b/app/src/main/assets/licenses/apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/app/src/main/assets/licenses/apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/src/main/assets/licenses/mit-ganjoor-android.txt b/app/src/main/assets/licenses/mit-ganjoor-android.txt new file mode 100644 index 0000000..971a32c --- /dev/null +++ b/app/src/main/assets/licenses/mit-ganjoor-android.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Anas Rashid + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/main/assets/licenses/ofl-libron.txt b/app/src/main/assets/licenses/ofl-libron.txt new file mode 100644 index 0000000..445b233 --- /dev/null +++ b/app/src/main/assets/licenses/ofl-libron.txt @@ -0,0 +1,94 @@ +Newsreader (c) 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader) +Readerly (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/readerly) +Libron (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/libron) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/licenses/ofl-noto-naskh-arabic.txt b/app/src/main/assets/licenses/ofl-noto-naskh-arabic.txt new file mode 100644 index 0000000..d4e1705 --- /dev/null +++ b/app/src/main/assets/licenses/ofl-noto-naskh-arabic.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/licenses/ofl-noto-nastaliq-urdu.txt b/app/src/main/assets/licenses/ofl-noto-nastaliq-urdu.txt new file mode 100644 index 0000000..38833b0 --- /dev/null +++ b/app/src/main/assets/licenses/ofl-noto-nastaliq-urdu.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/nastaliq) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt index eea2618..df0dcd0 100644 --- a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt +++ b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt @@ -147,6 +147,21 @@ private data class LivePoem(val id: Int = 0, val excerpt: String? = null) private val liveJson = Json { ignoreUnknownKeys = true } +@Serializable +data class SearchHit( + val id: Int = 0, + val title: String = "", + val fullTitle: String = "", + val fullUrl: String = "", + val plainText: String? = null, +) + +/** The line a search term actually appears on, rather than the opening line of the poem. */ +fun snippet(hit: SearchHit, term: String): String { + val lines = hit.plainText.orEmpty().lineSequence().map { it.trim() }.filter { it.isNotEmpty() } + return (lines.firstOrNull { it.contains(term) } ?: lines.firstOrNull()).orEmpty() +} + fun catPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}/_cat.json" fun poemPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}.json" @@ -273,6 +288,42 @@ object Ganjoor { }.getOrDefault(emptyMap()) } + /** + * Full-text search across the poems. + * + * The static data set carries no index, so this is ganjoor.net's own search. An empty + * [poetIds] searches every poet; otherwise one query runs per poet and the results merge, + * since the endpoint scopes to a single poet at a time. + * + * ponytail: online only. Searching offline would mean scanning the downloaded tree file by + * file, which is fine for one poet and hopeless for all of them. + */ + suspend fun search( + term: String, + poetIds: List, + page: Int, + pageSize: Int = 20, + ): List = withContext(Dispatchers.IO) { + if (term.isBlank() || offline) return@withContext emptyList() + val scopes: List = poetIds.ifEmpty { listOf(null) } + coroutineScope { + scopes.map { poetId -> + async { + val url = liveBase.newBuilder() + .addPathSegments("api/ganjoor/poems/search") + .addQueryParameter("term", term) + .addQueryParameter("PageNumber", page.toString()) + .addQueryParameter("PageSize", pageSize.toString()) + .also { if (poetId != null) it.addQueryParameter("poetId", poetId.toString()) } + .build() + runCatching { + liveJson.decodeFromString>(fetch(url)) + }.getOrDefault(emptyList()) + } + }.awaitAll().flatten() + } + } + /** * Saves a poet's whole tree — biography, every category and every poem — for offline reading. * Re-running it is cheap: anything already on disk is skipped, which is also how a download diff --git a/app/src/main/java/com/ganjoor/android/ui/AboutScreen.kt b/app/src/main/java/com/ganjoor/android/ui/AboutScreen.kt new file mode 100644 index 0000000..ad5b0bf --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/AboutScreen.kt @@ -0,0 +1,212 @@ +package com.ganjoor.android.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.ganjoor.android.R + +/** + * One credited component. [licenseAsset] names a file under `assets/licenses`; the OFL in + * particular requires the licence text to travel with the software, so it is bundled rather + * than linked. + */ +private data class Credit( + val name: String, + val holder: String, + val license: String, + val licenseAsset: String? = null, + val url: String, +) + +private val CREDITS = listOf( + Credit( + "Ganjoor", + "ganjoor.net — the poems themselves, and the search and first-line lookups", + "Classical Persian verse, long out of copyright. Ganjoor's own site and backend " + + "(GanjoorService) are GPL-3.0; this app uses none of their code, only data over HTTPS.", + url = "https://ganjoor.net", + ), + Credit( + "ganjoor-data", + "The static export this app reads", + "No licence stated by the publisher", + url = "https://github.com/anas-rashid/ganjoor-data", + ), + Credit( + "Noto Naskh Arabic", + "The Noto Project Authors", + "SIL Open Font License 1.1", + "ofl-noto-naskh-arabic.txt", + "https://github.com/notofonts/arabic", + ), + Credit( + "Noto Nastaliq Urdu", + "The Noto Project Authors", + "SIL Open Font License 1.1", + "ofl-noto-nastaliq-urdu.txt", + "https://github.com/notofonts/nastaliq", + ), + Credit( + "Libron", + "Nico Verbruggen, after Readerly and Newsreader", + "SIL Open Font License 1.1", + "ofl-libron.txt", + "https://github.com/nicoverbruggen/libron", + ), + Credit( + "Jetpack Compose, AndroidX", + "The Android Open Source Project", + "Apache License 2.0", + "apache-2.0.txt", + "https://developer.android.com/jetpack", + ), + Credit( + "Kotlin, kotlinx.serialization", + "JetBrains", + "Apache License 2.0", + "apache-2.0.txt", + "https://kotlinlang.org", + ), + Credit( + "OkHttp, Okio", + "Square, Inc.", + "Apache License 2.0", + "apache-2.0.txt", + "https://square.github.io/okhttp/", + ), + Credit( + "Coil", + "Coil Contributors", + "Apache License 2.0", + "apache-2.0.txt", + "https://coil-kt.github.io/coil/", + ), + Credit( + "Accompanist", + "The Android Open Source Project", + "Apache License 2.0", + "apache-2.0.txt", + "https://google.github.io/accompanist/", + ), + Credit( + "Ganjoor for Android", + "This app", + "MIT License", + "mit-ganjoor-android.txt", + "https://github.com/anas-rashid/ganjoorandroid", + ), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen(onUp: () -> Unit, onHome: () -> Unit) { + var showing by remember { mutableStateOf(null) } + + // The rest of the app is right-to-left because the poetry is, but these credits and licence + // texts are English prose, which is unreadable right-aligned. + val direction = + if (LocalSettings.current.value.language == Language.En) LayoutDirection.Ltr + else LayoutDirection.Rtl + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.about)) }, + navigationIcon = { + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + actions = { HomeAction(onHome) }, + ) + } + ) { insets -> + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = insets) { + item { + Text( + text = stringResource(R.string.about_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + HorizontalDivider() + } + items(CREDITS) { credit -> + ListItem( + headlineContent = { Text(credit.name) }, + supportingContent = { + Column { + Text(credit.holder, style = MaterialTheme.typography.bodySmall) + Text( + text = credit.license, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + Text(credit.url, style = MaterialTheme.typography.bodySmall) + } + }, + modifier = Modifier.clickable( + enabled = credit.licenseAsset != null, + ) { showing = credit }, + ) + HorizontalDivider() + } + } + } + + showing?.let { credit -> + val context = LocalContext.current + val text = remember(credit) { + runCatching { + context.assets.open("licenses/${credit.licenseAsset}").bufferedReader() + .use { it.readText() } + }.getOrDefault(credit.license) + } + AlertDialog( + onDismissRequest = { showing = null }, + title = { Text(credit.name) }, + text = { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.verticalScroll(rememberScrollState()), + ) + }, + confirmButton = { + TextButton(onClick = { showing = null }) { Text(stringResource(R.string.done)) } + }, + ) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt index ddd63ef..6adc2c2 100644 --- a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt +++ b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt @@ -30,12 +30,18 @@ data class CategoryRoute(val url: String) @Serializable data class PoemRoute(val url: String, val fromBookmarks: Boolean = false) +@Serializable +data class SearchRoute(val term: String = "") + @Serializable object BookmarksRoute @Serializable object DownloadsRoute +@Serializable +object AboutRoute + /** * Opens a page with the poet list as the only thing beneath it. * @@ -63,7 +69,13 @@ fun GanjoorApp() { val nav = rememberNavController() var settingsOpen by rememberSaveable { mutableStateOf(false) } - CompositionLocalProvider(LocalOpenReadingSettings provides { settingsOpen = true }) { + CompositionLocalProvider( + LocalOpenReadingSettings provides { settingsOpen = true }, + LocalOpenAbout provides { + settingsOpen = false + nav.navigate(AboutRoute) + }, + ) { NavHost( navController = nav, startDestination = PoetsRoute, @@ -77,6 +89,7 @@ fun GanjoorApp() { composable { PoetsScreen( onPoet = { nav.open(CategoryRoute(it)) }, + onSearchPoems = { nav.navigate(SearchRoute(it)) }, onBookmarks = { nav.navigate(BookmarksRoute) }, onDownloads = { nav.navigate(DownloadsRoute) }, ) @@ -112,6 +125,15 @@ fun GanjoorApp() { onCategory = { nav.open(CategoryRoute(it)) }, ) } + composable { entry -> + SearchScreen( + initialTerm = entry.toRoute().term, + onUp = { nav.navigateUp() }, + onHome = { nav.goHome() }, + // Searching is a list, like the saved poems, so Back returns to the results. + onPoem = { nav.navigate(PoemRoute(it, fromBookmarks = true)) }, + ) + } composable { BookmarksScreen( onUp = { nav.goHome() }, @@ -122,8 +144,12 @@ fun GanjoorApp() { composable { DownloadsScreen(onUp = { nav.goHome() }) } + composable { + AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() }) + } } - } - if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false }) + // Inside the provider: the sheet reads LocalOpenAbout, so it has to be in scope. + if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false }) + } } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt index c04c6ff..4a82537 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -24,9 +26,11 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -52,7 +56,15 @@ import com.ganjoor.android.data.PoetRef @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PoetsScreen(onPoet: (String) -> Unit, onBookmarks: () -> Unit, onDownloads: () -> Unit) { +fun PoetsScreen( + onPoet: (String) -> Unit, + onSearchPoems: (String) -> Unit, + onBookmarks: () -> Unit, + onDownloads: () -> Unit, +) { + // Hoisted so the search box can live in the bottom bar, within thumb reach. + var query by remember { mutableStateOf("") } + Scaffold( topBar = { TopAppBar( @@ -68,12 +80,18 @@ fun PoetsScreen(onPoet: (String) -> Unit, onBookmarks: () -> Unit, onDownloads: ReadingSettingsAction() }, ) - } + }, + bottomBar = { + SearchBar( + query = query, + onQueryChange = { query = it }, + onSearchPoems = { onSearchPoems(query.trim()) }, + ) + }, ) { insets -> Load(key = Unit, block = { Ganjoor.manifest() }) { manifest -> val settings = LocalSettings.current val sort = settings.value.poetSort - var query by remember { mutableStateOf("") } val poets = remember(query, manifest, sort) { val matches = if (query.isBlank()) manifest.poets @@ -116,16 +134,6 @@ fun PoetsScreen(onPoet: (String) -> Unit, onBookmarks: () -> Unit, onDownloads: } } } - item(span = { GridItemSpan(maxLineSpan) }) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - label = { Text(stringResource(R.string.search_poets)) }, - leadingIcon = { Icon(Icons.Default.Search, null) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - ) - } items(poets, key = { it.id }) { poet -> PoetCard(poet) { onPoet(poet.fullUrl) } } @@ -134,6 +142,40 @@ fun PoetsScreen(onPoet: (String) -> Unit, onBookmarks: () -> Unit, onDownloads: } } +/** + * Filters the poets as you type, and offers the same words to the poem search one tap further. + * It sits at the bottom of the screen: a phone held one-handed reaches here, not the top. + */ +@Composable +private fun SearchBar(query: String, onQueryChange: (String) -> Unit, onSearchPoems: () -> Unit) { + Surface(tonalElevation = 3.dp) { + Column( + modifier = Modifier + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + if (query.isNotBlank()) { + ListItem( + headlineContent = { Text(stringResource(R.string.search_poems_for, query.trim())) }, + leadingContent = { + Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary) + }, + modifier = Modifier.clickable(onClick = onSearchPoems), + ) + } + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + label = { Text(stringResource(R.string.search_poets)) }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + @Composable private fun PoetCard(poet: PoetRef, onClick: () -> Unit) { Card(modifier = Modifier.clickable(onClick = onClick)) { diff --git a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt index 64f2d42..1a5bd69 100644 --- a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Slider import androidx.compose.material3.Switch +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.staticCompositionLocalOf @@ -41,6 +42,9 @@ val LocalOpenReadingSettings = staticCompositionLocalOf<() -> Unit> { error("No reading settings host") } +/** Opens the About and licences screen; the sheet is the only place that needs it. */ +val LocalOpenAbout = staticCompositionLocalOf<() -> Unit> { error("No about host") } + /** Top-bar button that opens the reading settings sheet. */ @Composable fun ReadingSettingsAction() { @@ -123,6 +127,14 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) { ) { on -> settings.update { it.copy(showSummaries = on) } } + + val openAbout = LocalOpenAbout.current + TextButton( + onClick = openAbout, + modifier = Modifier.padding(top = 16.dp), + ) { + Text(stringResource(R.string.about)) + } } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/SearchScreen.kt b/app/src/main/java/com/ganjoor/android/ui/SearchScreen.kt new file mode 100644 index 0000000..2fe60b2 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/SearchScreen.kt @@ -0,0 +1,257 @@ +package com.ganjoor.android.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ganjoor.android.R +import com.ganjoor.android.data.Ganjoor +import com.ganjoor.android.data.PoetRef +import com.ganjoor.android.data.SearchHit +import com.ganjoor.android.data.snippet +import kotlinx.coroutines.delay + +private const val PAGE_SIZE = 20 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + initialTerm: String, + onUp: () -> Unit, + onHome: () -> Unit, + onPoem: (String) -> Unit, +) { + var term by remember { mutableStateOf(initialTerm) } + // Empty means every poet; otherwise the search is scoped to the ones picked. + val poets = remember { mutableStateListOf() } + var page by remember { mutableIntStateOf(1) } + val hits = remember { mutableStateListOf() } + var loading by remember { mutableStateOf(false) } + var exhausted by remember { mutableStateOf(false) } + var picking by remember { mutableStateOf(false) } + val offline = LocalSettings.current.value.offline + + // Re-run from the first page whenever the term or the scope changes. + LaunchedEffect(term, poets.toList()) { + page = 1; exhausted = false; hits.clear() + if (term.isBlank()) return@LaunchedEffect + delay(400) // let typing settle before hitting the network + loading = true + hits += Ganjoor.search(term, poets.map { it.id }, page = 1, pageSize = PAGE_SIZE) + exhausted = hits.size < PAGE_SIZE + loading = false + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.search_poems)) }, + navigationIcon = { + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + actions = { HomeAction(onHome) }, + ) + }, + bottomBar = { + Surface(tonalElevation = 3.dp) { + OutlinedTextField( + value = term, + onValueChange = { term = it }, + label = { Text(stringResource(R.string.search)) }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + singleLine = true, + modifier = Modifier + .navigationBarsPadding() + .imePadding() + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + }, + ) { insets -> + Column(Modifier.fillMaxSize().padding(insets)) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = poets.isEmpty(), + onClick = { poets.clear() }, + label = { Text(stringResource(R.string.all_poets)) }, + ) + poets.forEach { poet -> + FilterChip( + selected = true, + onClick = { poets.remove(poet) }, + label = { Text(poet.nickname) }, + ) + } + FilterChip( + selected = false, + onClick = { picking = true }, + label = { Text(stringResource(R.string.choose_poets)) }, + ) + } + + when { + offline -> Message(R.string.search_needs_connection) + term.isBlank() -> Unit + loading && hits.isEmpty() -> Box( + Modifier.fillMaxSize(), + Alignment.Center, + ) { CircularProgressIndicator() } + + hits.isEmpty() -> Message(R.string.no_results) + + else -> LazyColumn(Modifier.fillMaxSize()) { + items(hits, key = { "${it.id}-${it.fullUrl}" }) { hit -> + ListItem( + headlineContent = { + Text(hit.fullTitle, maxLines = 2, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Text( + text = snippet(hit, term), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier.clickable { onPoem(hit.fullUrl) }, + ) + HorizontalDivider() + } + if (!exhausted) { + item { + TextButton( + onClick = { page++ }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + if (loading) CircularProgressIndicator(Modifier.heightIn(max = 20.dp)) + else Text(stringResource(R.string.load_more)) + } + } + } + } + } + } + } + + // Paging: only fires for pages after the first, which the term/scope effect handles. + LaunchedEffect(page) { + if (page == 1 || term.isBlank()) return@LaunchedEffect + loading = true + val more = Ganjoor.search(term, poets.map { it.id }, page = page, pageSize = PAGE_SIZE) + hits += more + exhausted = more.size < PAGE_SIZE + loading = false + } + + if (picking) { + PoetPicker( + selected = poets, + onDismiss = { picking = false }, + ) + } +} + +@Composable +private fun Message(resId: Int) { + Box(Modifier.fillMaxSize().padding(24.dp), Alignment.Center) { + Text(stringResource(resId), color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** Tick any number of poets to narrow the search; ticking none means all of them. */ +@Composable +private fun PoetPicker(selected: MutableList, onDismiss: () -> Unit) { + var query by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.choose_poets)) }, + text = { + Load(key = Unit, block = { Ganjoor.manifest() }) { manifest -> + val shown = remember(query, manifest) { + if (query.isBlank()) manifest.poets + else manifest.poets.filter { it.nickname.contains(query.trim(), true) } + } + Column { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text(stringResource(R.string.search_poets)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn(Modifier.heightIn(max = 380.dp)) { + items(shown, key = { it.id }) { poet -> + val checked = selected.any { it.id == poet.id } + ListItem( + headlineContent = { Text(poet.nickname) }, + leadingContent = { + Checkbox( + checked = checked, + onCheckedChange = { + if (checked) selected.removeAll { it.id == poet.id } + else selected.add(poet) + }, + ) + }, + modifier = Modifier.clickable { + if (checked) selected.removeAll { it.id == poet.id } + else selected.add(poet) + }, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.done)) } + }, + ) +} diff --git a/app/src/main/java/com/ganjoor/android/ui/Settings.kt b/app/src/main/java/com/ganjoor/android/ui/Settings.kt index 2e4296b..ccccc2a 100644 --- a/app/src/main/java/com/ganjoor/android/ui/Settings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/Settings.kt @@ -18,6 +18,9 @@ enum class ThemeMode(@StringRes val label: Int) { Dark(R.string.theme_dark), Sepia(R.string.theme_sepia), SepiaDark(R.string.theme_sepia_dark), + + /** Pure black, so OLED panels can switch the pixels off entirely. */ + Black(R.string.theme_black), } enum class ReadingFont(@StringRes val label: Int) { diff --git a/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt b/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt index 9691dcd..eea1d0c 100644 --- a/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt @@ -64,6 +64,36 @@ private val DarkScheme = darkColorScheme( outline = Color(0xFF899393), ) +/** + * True black, for OLED panels: a black pixel is an unlit pixel, so a night-time reading session + * costs noticeably less battery than the regular dark theme's dark grey. Surfaces step up in + * near-black greys so cards and sheets stay distinguishable without lighting the whole screen. + */ +private val BlackScheme = darkColorScheme( + primary = Color(0xFF80D4DA), + onPrimary = Color(0xFF00363A), + primaryContainer = Color(0xFF004F53), + onPrimaryContainer = Color(0xFF9CF1F6), + secondary = Color(0xFFFFB873), + onSecondary = Color(0xFF4A2800), + secondaryContainer = Color(0xFF693C00), + onSecondaryContainer = Color(0xFFFFDCBE), + background = Color(0xFF000000), + onBackground = Color(0xFFE3E3E3), + surface = Color(0xFF000000), + onSurface = Color(0xFFE3E3E3), + surfaceVariant = Color(0xFF1C1C1C), + onSurfaceVariant = Color(0xFFBDBDBD), + outline = Color(0xFF6E6E6E), + surfaceBright = Color(0xFF262626), + surfaceDim = Color(0xFF000000), + surfaceContainerLowest = Color(0xFF000000), + surfaceContainerLow = Color(0xFF0A0A0A), + surfaceContainer = Color(0xFF101010), + surfaceContainerHigh = Color(0xFF1A1A1A), + surfaceContainerHighest = Color(0xFF242424), +) + // Aged paper, for long reading sessions. private val SepiaScheme = lightColorScheme( primary = Color(0xFF7A4E24), @@ -121,11 +151,12 @@ fun GanjoorTheme(mode: ThemeMode, language: Language, content: @Composable () -> val dark = when (mode) { ThemeMode.System -> isSystemInDarkTheme() ThemeMode.Light, ThemeMode.Sepia -> false - ThemeMode.Dark, ThemeMode.SepiaDark -> true + ThemeMode.Dark, ThemeMode.SepiaDark, ThemeMode.Black -> true } val scheme = when (mode) { ThemeMode.Sepia -> SepiaScheme ThemeMode.SepiaDark -> SepiaDarkScheme + ThemeMode.Black -> BlackScheme else -> if (dark) DarkScheme else LightScheme } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index e30a508..be9c6d6 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -49,4 +49,16 @@ رونوشت شد دریافت %d شاعر خانه + جست‌وجو + جست‌وجوی شعرها + جست‌وجوی «%s» در شعرها + همهٔ شاعران + انتخاب شاعران + چیزی یافت نشد. + جست‌وجو به اینترنت نیاز دارد. حالت برون‌خط را خاموش کنید. + بیشتر + تمام + سیاه (OLED) + درباره و پروانه‌ها + گنجور برای اندروید نرم‌افزار آزاد است. شعرها، قلم‌ها و همهٔ کتابخانه‌هایی که این برنامه بر آن‌ها ساخته شده در زیر آمده‌اند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید. diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 311f932..dc31028 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -49,4 +49,16 @@ نقل ہو گیا %d شعرا ڈاؤن لوڈ کریں ہوم + تلاش + کلام میں تلاش + کلام میں «%s» تلاش کریں + تمام شعرا + شعرا منتخب کریں + کچھ نہیں ملا۔ + تلاش کے لیے انٹرنیٹ درکار ہے۔ آف لائن موڈ بند کریں۔ + مزید + مکمل + سیاہ (OLED) + تعارف اور لائسنس + گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2917eef..9d7ff35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,4 +54,16 @@ ‹ %s Download %d poets Home + Search + Search poems + Search poems for “%s” + All poets + Choose poets + Nothing found. + Search needs a connection. Turn off offline mode to use it. + Load more + Done + Black (OLED) + About & licences + Ganjoor for Android is free software. The poems, the fonts and every library it is built on are credited below; tap an entry to read its full licence. diff --git a/app/src/test/java/com/ganjoor/android/SearchHitTest.kt b/app/src/test/java/com/ganjoor/android/SearchHitTest.kt new file mode 100644 index 0000000..76fdc55 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/SearchHitTest.kt @@ -0,0 +1,64 @@ +package com.ganjoor.android + +import com.ganjoor.android.data.SearchHit +import com.ganjoor.android.data.snippet +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Trimmed from a real api.ganjoor.net response: the live endpoint returns ~30 fields per poem, + * so the decoder has to ignore almost all of them. + */ +private const val RESPONSE = """ +[ + { + "id": 2130, + "title": "غزل شمارهٔ ۱", + "fullTitle": "حافظ » غزلیات » غزل شمارهٔ ۱", + "urlSlug": "sh1", + "fullUrl": "/hafez/ghazal/sh1", + "plainText": "الا یا ایها الساقی ادر کأسا و ناولها\nکه عشق آسان نمود اول ولی افتاد مشکل ها\nبه بوی نافه ای کآخر صبا زان طره بگشاید", + "htmlText": "
…
", + "sourceName": "ویکی‌درج", + "published": true, + "coupletsCount": 7, + "verses": [], + "recitations": [] + } +] +""" + +class SearchHitTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `a live search response decodes despite its many unused fields`() { + val hits = json.decodeFromString>(RESPONSE) + + assertEquals(1, hits.size) + assertEquals("/hafez/ghazal/sh1", hits[0].fullUrl) + assertEquals("حافظ » غزلیات » غزل شمارهٔ ۱", hits[0].fullTitle) + } + + @Test + fun `the snippet is the line the term is on, not the opening line`() { + val hit = json.decodeFromString>(RESPONSE).first() + + assertTrue(snippet(hit, "نافه").contains("نافه")) + assertTrue(snippet(hit, "عشق").startsWith("که عشق")) + } + + @Test + fun `a term that isn't there falls back to the opening line`() { + val hit = json.decodeFromString>(RESPONSE).first() + + assertEquals("الا یا ایها الساقی ادر کأسا و ناولها", snippet(hit, "زرافه")) + } + + @Test + fun `a poem with no text yields an empty snippet rather than throwing`() { + assertEquals("", snippet(SearchHit(id = 1), "عشق")) + } +} diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/1-poets.png b/fastlane/metadata/android/fa/images/phoneScreenshots/1-poets.png new file mode 100644 index 0000000..bcae0f8 Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/1-poets.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/2-poem-list.png b/fastlane/metadata/android/fa/images/phoneScreenshots/2-poem-list.png new file mode 100644 index 0000000..02314cf Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/2-poem-list.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/3-reader-nastaliq-sepia.png b/fastlane/metadata/android/fa/images/phoneScreenshots/3-reader-nastaliq-sepia.png new file mode 100644 index 0000000..bf59101 Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/3-reader-nastaliq-sepia.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/4-themes-oled.png b/fastlane/metadata/android/fa/images/phoneScreenshots/4-themes-oled.png new file mode 100644 index 0000000..a13ed24 Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/4-themes-oled.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/5-offline-downloads.png b/fastlane/metadata/android/fa/images/phoneScreenshots/5-offline-downloads.png new file mode 100644 index 0000000..ce7d389 Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/5-offline-downloads.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/6-bookmarks.png b/fastlane/metadata/android/fa/images/phoneScreenshots/6-bookmarks.png new file mode 100644 index 0000000..61e9054 Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/6-bookmarks.png differ diff --git a/fastlane/metadata/android/fa/images/phoneScreenshots/7-licences.png b/fastlane/metadata/android/fa/images/phoneScreenshots/7-licences.png new file mode 100644 index 0000000..24f1c37 Binary files /dev/null and b/fastlane/metadata/android/fa/images/phoneScreenshots/7-licences.png differ diff --git a/licenses/README.md b/licenses/README.md new file mode 100644 index 0000000..1dcd69b --- /dev/null +++ b/licenses/README.md @@ -0,0 +1,56 @@ +# Licences + +Everything this app is built from, and the terms it comes under. The app itself is MIT; see +[`../LICENSE`](../LICENSE). + +The full text of each licence below also ships **inside the APK**, under +`assets/licenses/`, and is readable in the app at **Reading settings → About & licences**. The +SIL Open Font License requires the licence to travel with the software, so the font licences are +bundled rather than merely linked. + +## Content + +| What | Source | Terms | +|---|---|---| +| The poems | [ganjoor.net](https://ganjoor.net) | Classical Persian verse, long out of copyright | +| The data export | [ganjoor-data](https://github.com/anas-rashid/ganjoor-data) | **No licence file.** Worth asking upstream to add one | +| Poem search, first lines | `api.ganjoor.net` | A public web service. Its implementation, [GanjoorService](https://github.com/ganjoor/GanjoorService), is GPL-3.0 — this app uses none of that code, only data over HTTPS, so it is not a derivative work | +| AI summaries | Ganjoor's own feature, shown only when enabled | Labelled in the app as AI-generated | + +Nothing in Ganjoor's repositories restricts AI-assisted use. + +## Fonts + +| Font | Copyright | Licence | File | +|---|---|---|---| +| Noto Naskh Arabic | The Noto Project Authors | SIL OFL 1.1 | [`OFL-NotoNaskhArabic.txt`](OFL-NotoNaskhArabic.txt) | +| Noto Nastaliq Urdu | The Noto Project Authors | SIL OFL 1.1 | [`OFL-NotoNastaliqUrdu.txt`](OFL-NotoNastaliqUrdu.txt) | +| [Libron](https://github.com/nicoverbruggen/libron) | Nico Verbruggen, after Readerly and Newsreader (Production Type) | SIL OFL 1.1 | [`OFL-Libron.txt`](OFL-Libron.txt) | + +## Libraries + +Every dependency is Apache-2.0. The full text is in +[`../app/src/main/assets/licenses/apache-2.0.txt`](../app/src/main/assets/licenses/apache-2.0.txt). + +| Library | Copyright | +|---|---| +| Jetpack Compose, AndroidX (core, activity, lifecycle, navigation) | The Android Open Source Project | +| Material Components / Material icons | The Android Open Source Project | +| Accompanist (drawablepainter, pulled in by Coil) | The Android Open Source Project | +| Kotlin standard library, kotlinx.serialization, kotlinx.coroutines | JetBrains | +| OkHttp, Okio | Square, Inc. | +| Coil 3 | Coil Contributors | + +No Google Play Services, Firebase, analytics or tracking of any kind. The only Android +permission requested is `INTERNET`. + +## Artwork + +The launcher icon — an eight-point *shamsa* — is original work for this project, MIT like the +rest of the code. Ganjoor's own app icons are unlicensed and are their mark; they are not used +here. + +## Poet portraits + +Fetched at runtime from `api.ganjoor.net`. They are Ganjoor's own illustrations of +long-dead poets, displayed as served and never redistributed in the APK.