commit 764ed2735fbad1c007576453b4a56dfbd175ac1b equwal <13551856+equwal@users.noreply.github.com> 2026-09-25 02:16:03 -0700 Add SubRead Anki One tap makes an Anki card in AnkiDroid: the word, its reading and definition from SubRead Dictionary, the subtitle line of now from SubRead Overlay, the sound of that line from a capture of the player, and a picture of the screen. Four doors into one activity that shows nothing: the text selection menu, the share sheet, the action space.subread.anki.action.ADD from another app, and the link subreadanki://add for a web page. The core is plain Kotlin with property tests: the request and its query, the sentence around a word, the clock of a player report, the ring of captured sound, and the fields of a note type.
.gitignore | 20 + LICENSE | 662 +++++++++++++++++++++ README.md | 191 ++++++ app/build.gradle.kts | 77 +++ app/src/main/AndroidManifest.xml | 116 ++++ .../main/kotlin/space/subread/anki/AacEncoder.kt | 79 +++ .../main/kotlin/space/subread/anki/AddActivity.kt | 171 ++++++ .../main/kotlin/space/subread/anki/AnkiClient.kt | 109 ++++ app/src/main/kotlin/space/subread/anki/AudioCut.kt | 139 +++++ .../kotlin/space/subread/anki/CaptureActivity.kt | 61 ++ .../kotlin/space/subread/anki/CaptureService.kt | 268 +++++++++ .../main/kotlin/space/subread/anki/CaptureTile.kt | 35 ++ .../kotlin/space/subread/anki/DictionaryClient.kt | 86 +++ .../main/kotlin/space/subread/anki/MainActivity.kt | 333 +++++++++++ app/src/main/kotlin/space/subread/anki/Media.kt | 137 +++++ app/src/main/kotlin/space/subread/anki/Miner.kt | 165 +++++ .../kotlin/space/subread/anki/OverlayClient.kt | 89 +++ app/src/main/kotlin/space/subread/anki/Requests.kt | 66 ++ app/src/main/kotlin/space/subread/anki/Store.kt | 68 +++ app/src/main/res/drawable/icon.xml | 14 + app/src/main/res/values/strings.xml | 84 +++ app/src/main/res/xml/file_paths.xml | 5 + build.gradle.kts | 5 + core/build.gradle.kts | 29 + .../main/kotlin/space/subread/anki/core/Fields.kt | 132 ++++ .../kotlin/space/subread/anki/core/MediaNames.kt | 26 + .../kotlin/space/subread/anki/core/MineRequest.kt | 175 ++++++ .../kotlin/space/subread/anki/core/NoteType.kt | 67 +++ .../main/kotlin/space/subread/anki/core/PcmRing.kt | 92 +++ .../kotlin/space/subread/anki/core/PlayerReport.kt | 66 ++ .../kotlin/space/subread/anki/core/Sentences.kt | 90 +++ .../kotlin/space/subread/anki/core/CoreTest.kt | 393 ++++++++++++ fastlane/metadata/android/en-US/changelogs/1.txt | 1 + .../metadata/android/en-US/full_description.txt | 20 + .../metadata/android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + fdroid/space.subread.anki.yml | 26 + gradle.properties | 5 + gradle/libs.versions.toml | 27 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++++++ gradlew.bat | 94 +++ settings.gradle.kts | 31 + 44 files changed, 4516 insertions(+)
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02bd9b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +.kotlin/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Fixtures made from text that is not public domain stay on the machine +# that generated them. Tests skip this directory when it is missing. +core/src/test/resources/golden-local/ + +# On-device test media. Bring your own pair; the test skips without it. +app/src/androidTest/assets/local/ + + +# Python byte code +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe6b903 --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <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 Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2572e0 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# SubRead Anki + +One tap makes an Anki card: the word, its reading and definition, the +sentence it is in, the sound of that sentence, and a picture of the screen. +The card goes into [AnkiDroid](https://github.com/ankidroid/Anki-Android) +on the device. + +The tap can come from anywhere: a word of the subtitle line of +[SubRead Overlay](https://github.com/equwal/subread-overlay), the text +selection menu of any app (a reader, a browser, an OCR app), the share sheet, +another app, or a link on a web page. The app fills in what the tap did not +say: the definition from +[SubRead Dictionary](https://github.com/equwal/subread-dictionary), the +sentence and its times from the overlay, the sound and the picture from the +capture. It shows nothing of its own: a toast says "Added to Anki: 猫" and +the reader or the player stays in view. + +Nothing leaves the device. The app talks to AnkiDroid, SubRead Overlay and +SubRead Dictionary on the device, and opens a URL only when a sender gives +one for an audio or a picture. + +## How to use it + +1. Install AnkiDroid and allow SubRead Anki to add cards to it. AnkiDroid + must have "Enable AnkiDroid API" on, under Advanced in its settings; it + is on by default. +2. Choose the deck. Choose the note type, or let the app make its own, + "SubRead Anki". The app guesses what goes into each field from the field + names: the [Lapis](https://github.com/donkuri/lapis) note type and the + basic ones work as they are. "Fields" changes the guess. +3. Install SubRead Dictionary and import a dictionary into it, for the + reading, the definition, the pitch accent, the frequency and the word + audio. +4. Install SubRead Overlay, and choose "Anki" as its dictionary. A tap on a + word of the subtitle line then makes the card: the line is the sentence, + and its times cut the sound. +5. Start the capture, from the app or from its tile in the quick settings. + Android asks each time. While the capture is on, the app keeps the last + minute and a half of the sound of the device and the last picture of the + screen, and each card gets the sound of its line and the picture. Stop it + from the notification or the tile. + +In any other app: select a word and choose "Anki" in the text selection +menu. The sentence comes from the overlay when the word is in the line of +now. Select a whole sentence, and the card is a sentence card. Share a +picture to "Anki" within an hour of a card, and it goes onto that card. + +Two options: "Ask before adding" shows the sentence and the terms of the +dictionary first, with one tap to add; off, one tap adds. "A word that is in +the deck already: skip" keeps a second tap from making a second card. + +## What goes on the card + +| Part | Where it comes from | +|---|---| +| Expression, reading, definition, pitch, frequency | SubRead Dictionary, for the longest term at the start of the selection. `食べた` gives the card `食べる`. A sender can give its own. | +| Sentence | The sender, else the subtitle line of now from SubRead Overlay when the selection is in it. The word is in `<b>` as it is in the sentence. | +| Sentence audio | The sender's file, cut to the times it gives. Else the capture, cut to the times of the subtitle line on the clock of the player, with a pad on each side. Without a line, the eight seconds before the tap. AAC in `.m4a`: every player and every Anki has it. | +| Word audio | The sender, else the audio of SubRead Dictionary (the local audio server, or a remote source when on). | +| Picture | The sender, else the newest picture of the screen from the capture. JPEG, at most 1600 pixels on the long side. | +| Source, tags | The sender. The tags of the settings go on every card. | + +The note type "SubRead Anki" has the fields Expression, Reading, Definition, +Sentence, SentenceAudio, WordAudio, Image and Source, and one card: the word +and the sentence in front, the rest behind, black on white. + +## For other apps + +Four doors, all into one activity, `space.subread.anki.AddActivity`. It +shows nothing and closes when the card is added. Any part can be left out: +the app fills it in as the table above says. + +**The text selection menu and the share sheet.** No code: the app has an +entry in both. `Intent.ACTION_PROCESS_TEXT` with `EXTRA_PROCESS_TEXT`, or +`Intent.ACTION_SEND` with `text/plain` and `EXTRA_TEXT`. The text is the +word. `ACTION_SEND` with an `image/*` stream and a text is the picture and +the word; the picture alone goes onto the last card. + +**An Intent from an app on the device.** The action +`space.subread.anki.action.ADD`, with the keys of the table below as extras, +each with the prefix `space.subread.anki.extra.` and in upper case: + +```kotlin +val add = Intent("space.subread.anki.action.ADD").apply { + setPackage("space.subread.anki") + putExtra("space.subread.anki.extra.EXPRESSION", "食べた") + putExtra("space.subread.anki.extra.SENTENCE", "猫が魚を食べた。") + putExtra("space.subread.anki.extra.AUDIO", audioUri) // Uri, or a String + putExtra("space.subread.anki.extra.AUDIO_START", 96_000L) // Long, or a String + putExtra("space.subread.anki.extra.AUDIO_END", 99_500L) + putExtra("space.subread.anki.extra.IMAGE", pageUri) + putExtra("space.subread.anki.extra.SOURCE", "吾輩は猫である") + clipData = ClipData.newRawUri("audio", audioUri).apply { addItem(ClipData.Item(pageUri)) } + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +} +startActivityForResult(add, REQUEST_CARD) // or startActivity +``` + +A `content://` Uri must be one the app may grant: its own `FileProvider`, or +a document Uri it holds a permission for. A `file://` Uri is refused. Declare +the package in the manifest of the calling app, or Android 11 and later hide +it: + +```xml +<queries> + <package android:name="space.subread.anki" /> +</queries> +``` + +The result, for a caller that started the activity for one: `RESULT_OK` with +`space.subread.anki.extra.NOTE_ID` (`Long`, the note in AnkiDroid), or +`RESULT_CANCELED` with `space.subread.anki.extra.ERROR` (`String`): `empty`, +`no_ankidroid`, `no_permission`, `duplicate`, `cancelled`, or what AnkiDroid +said. + +**A link on a web page.** The same keys, percent-encoded, in the query of +`subreadanki://add`. A web reader that has the sentence, the audiobook and +the times makes the card with one link: + +```javascript +const query = new URLSearchParams({ + expression: "食べた", + sentence: "猫が魚を食べた。", + audio: "https://example.com/books/neko.m4b", // the whole file: the app reads the range alone + audio_start: "96000", + audio_end: "99500", + image: "https://example.com/books/neko/page-12.jpg", + source: "吾輩は猫である", +}).toString(); +// Chrome: an intent link, with a fallback for a phone without the app. +location.href = "intent://add?" + query + "#Intent;scheme=subreadanki;package=space.subread.anki;" + + "S.browser_fallback_url=" + encodeURIComponent("https://github.com/equwal/subread-anki") + ";end"; +// Firefox and the others: the plain link. +// location.href = "subreadanki://add?" + query; +``` + +The link must come from a tap of the user: browsers block an app link +without one. An `http(s)` audio is read with range requests, so a whole +audiobook is fine; a picture is fetched whole. A `data:` Uri works for a +small file. The app shows a toast and goes back to the browser. + +**The keys.** Each is optional; a card needs `expression` or `sentence`. + +| Key | Value | +|---|---| +| `expression` | The word, as it is in the text. | +| `reading` | The reading of the word. | +| `definition` | The definition, HTML or text. Given, the dictionary is not asked. | +| `sentence` | The sentence, plain text. | +| `audio` | A Uri of the audio of the sentence: `content://`, `https://`, `http://` or `data:`. | +| `audio_start`, `audio_end` | With `audio`: the clip, in milliseconds of the audio. Without them, the whole file is the clip. | +| `word_audio` | A Uri of the audio of the word alone. | +| `image` | A Uri of the picture. | +| `source` | Where the sentence is from: the title of the book or the video. | +| `tags` | Anki tags, separated by spaces. | +| `pitch`, `frequency` | Text for the pitch and frequency fields. | +| `position` | The position of the player at the tap, in milliseconds. With the capture on and no `audio`, the clip is cut around it. | +| `cue_start`, `cue_end` | The subtitle line, in milliseconds on the clock of the player. With the capture on and no `audio`, the clip is this range. | +| `confirm` | `1`: show the card before it is added, whatever the setting says. `0`: add at once. | + +## How it works + +`:core` is plain Kotlin, with no Android in it, and has property tests: the +request and its query, the sentence around a word, the clock of a player +report, the ring of captured sound, the fields of a note type. `:app` has +the activity, the capture service, the AnkiDroid client and the two content +provider clients. + +The sound of the capture is 16-bit PCM in a ring with a clock. A player +reports a position, the time of that report and the speed; the app maps the +times of the subtitle line onto the clock of the ring and reads that range +out. A pause reported a minute ago still maps its media times to the right +moments. The clip is encoded with the AAC encoder of Android. + +SubRead Overlay answers `content://space.subread.overlay.player/line` with +the line of now and the report of the player. SubRead Dictionary answers +`content://space.subread.dictionary.lookup/lookup?text=…` with the terms, +and gives the word audio through the same provider. Their READMEs have the +details. + +## Build + +``` +./gradlew :core:test :app:assembleDebug +``` + +`-PplayStore=true` leaves the Ko-fi link out of the build for Google Play. + +## Licence + +AGPL-3.0. See `LICENSE`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..604a4d6 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.android.application) +} + +// Release signing comes from the environment, so the key never lives in the repository. +val keystorePath = providers.environmentVariable("SUBREAD_KEYSTORE_FILE").orNull +val keystorePassword = providers.environmentVariable("SUBREAD_KEYSTORE_PASSWORD").orNull +val signingReady = !keystorePath.isNullOrBlank() && !keystorePassword.isNullOrBlank() && + file(keystorePath).isFile + +android { + namespace = "space.subread.anki" + compileSdk = 36 + + // No list of dependencies, encrypted for Google alone, inside the app: F-Droid + // does not accept a part of the app that nobody else can read. + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + + defaultConfig { + applicationId = "space.subread.anki" + minSdk = 26 + targetSdk = 36 + // Plain numbers, in this file: F-Droid reads them from here to find a new release. + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // A link to the Ko-fi page. Google Play is not given it (-PplayStore=true), the same + // as in the other SubRead apps. + buildConfigField("boolean", "DONATE_LINK", (providers.gradleProperty("playStore").orNull != "true").toString()) + } + + if (signingReady) { + signingConfigs { + create("release") { + storeFile = file(keystorePath!!) + storePassword = keystorePassword + keyAlias = "subread" + keyPassword = keystorePassword + } + } + } + + buildTypes { + debug { applicationIdSuffix = ".debug" } + release { + isMinifyEnabled = false + if (signingReady) signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { buildConfig = true } + + lint { + // The AnkiDroid API library ships the lint rules of the AnkiDroid app itself. This one + // wants the clock of the Anki collection; this app has no collection. + disable += "DirectSystemCurrentTimeMillisUsage" + } +} + +dependencies { + implementation(project(":core")) + implementation(libs.androidx.core.ktx) + implementation(libs.ankidroid.api) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.core) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d12c30a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,116 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- The cards go into AnkiDroid through its API. Android asks the user once. --> + <uses-permission android:name="com.ichi2.anki.permission.READ_WRITE_DATABASE" /> + + <!-- For an audio or a picture at a URL that a sender gives, for example a web reader. Nothing else. --> + <uses-permission android:name="android.permission.INTERNET" /> + + <!-- + The capture: the sound and the screen of the player, while the user has it on. Android + asks the user each time the capture starts, and shows it in the status bar. + --> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + + <!-- The apps this one talks to. Android 11 and later hide other apps without this. --> + <queries> + <package android:name="com.ichi2.anki" /> + <package android:name="space.subread.overlay" /> + <package android:name="space.subread.dictionary" /> + </queries> + + <application + android:allowBackup="true" + android:icon="@drawable/icon" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@android:style/Theme.DeviceDefault.Light.NoActionBar"> + + <!-- The settings: AnkiDroid, the deck, the note type, the capture. --> + <activity + android:name=".MainActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + + <!-- + The one way in. It shows nothing: it takes the request, fills what is missing, + adds the card and closes. Five doors: the text selection menu of Android, the share + sheet (a text or a picture), the action space.subread.anki.action.ADD from another + app, and the link subreadanki://add from a web page. See README.md. + --> + <activity + android:name=".AddActivity" + android:excludeFromRecents="true" + android:exported="true" + android:label="@string/add_label" + android:taskAffinity="" + android:theme="@android:style/Theme.Translucent.NoTitleBar"> + <intent-filter> + <action android:name="android.intent.action.PROCESS_TEXT" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="text/plain" /> + </intent-filter> + <intent-filter> + <action android:name="android.intent.action.SEND" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="text/plain" /> + <data android:mimeType="image/*" /> + </intent-filter> + <intent-filter> + <action android:name="space.subread.anki.action.ADD" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data android:scheme="subreadanki" android:host="add" /> + </intent-filter> + </activity> + + <!-- Asks Android for the capture, then starts the service. Shows nothing of its own. --> + <activity + android:name=".CaptureActivity" + android:excludeFromRecents="true" + android:exported="false" + android:taskAffinity="" + android:theme="@android:style/Theme.Translucent.NoTitleBar" /> + + <!-- Keeps the last minutes of the sound of the player and the last picture of the screen. --> + <service + android:name=".CaptureService" + android:exported="false" + android:foregroundServiceType="mediaProjection" /> + + <!-- The tile in the quick settings: capture on, capture off. --> + <service + android:name=".CaptureTile" + android:exported="true" + android:icon="@drawable/icon" + android:label="@string/tile_label" + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + <intent-filter> + <action android:name="android.service.quicksettings.action.QS_TILE" /> + </intent-filter> + </service> + + <!-- Hands a media file to AnkiDroid, which copies it into the collection. --> + <provider + android:name=".MediaProvider" + android:authorities="${applicationId}.files" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/file_paths" /> + </provider> + </application> +</manifest> diff --git a/app/src/main/kotlin/space/subread/anki/AacEncoder.kt b/app/src/main/kotlin/space/subread/anki/AacEncoder.kt new file mode 100644 index 0000000..454bf62 --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/AacEncoder.kt @@ -0,0 +1,79 @@ +package space.subread.anki + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import java.io.File +import java.nio.ByteOrder + +/** + * Writes 16-bit PCM as AAC in an `.m4a`. AAC because every player has it, Anki on iOS + * included, and because Android has the encoder on each device since API 16. + */ +object AacEncoder { + + private const val MIME = MediaFormat.MIMETYPE_AUDIO_AAC + private const val TIMEOUT_US = 10_000L + + /** Encodes [pcm], interleaved with [channels] channels at [sampleRate], into [out]. */ + fun encode(pcm: ShortArray, sampleRate: Int, channels: Int, out: File) { + require(pcm.isNotEmpty()) { "no sound to encode" } + val format = MediaFormat.createAudioFormat(MIME, sampleRate, channels).apply { + setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + setInteger(MediaFormat.KEY_BIT_RATE, 64_000 * channels) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 64 * 1024) + } + val codec = MediaCodec.createEncoderByType(MIME) + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + codec.start() + val muxer = MediaMuxer(out.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + var track = -1 + var fed = 0 + var inputDone = false + var outputDone = false + val info = MediaCodec.BufferInfo() + try { + while (!outputDone) { + if (!inputDone) { + val index = codec.dequeueInputBuffer(TIMEOUT_US) + if (index >= 0) { + val buffer = codec.getInputBuffer(index)!! + buffer.clear() + // A frame must stay whole: the count is a multiple of the channels. + val count = minOf(buffer.capacity() / 2, pcm.size - fed) / channels * channels + buffer.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(pcm, fed, count) + val timeUs = fed / channels * 1_000_000L / sampleRate + fed += count + val flags = if (fed >= pcm.size) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0 + if (flags != 0) inputDone = true + codec.queueInputBuffer(index, 0, count * 2, timeUs, flags) + } + } + val index = codec.dequeueOutputBuffer(info, TIMEOUT_US) + when { + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + track = muxer.addTrack(codec.outputFormat) + muxer.start() + } + index >= 0 -> { + val buffer = codec.getOutputBuffer(index)!! + val config = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 + if (!config && info.size > 0 && track >= 0) { + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + muxer.writeSampleData(track, buffer, info) + } + codec.releaseOutputBuffer(index, false) + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outputDone = true + } + } + } + } finally { + runCatching { codec.stop() } + codec.release() + runCatching { if (track >= 0) muxer.stop() } + muxer.release() + } + } +} diff --git a/app/src/main/kotlin/space/subread/anki/AddActivity.kt b/app/src/main/kotlin/space/subread/anki/AddActivity.kt new file mode 100644 index 0000000..5616b02 --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/AddActivity.kt @@ -0,0 +1,171 @@ +package space.subread.anki + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.widget.Toast +import space.subread.anki.core.MineRequest +import space.subread.anki.core.Sentences +import kotlin.concurrent.thread + +/** + * The one way in. It takes the request from the Intent, has [Miner] fill and add the card, + * says what happened in a toast, and closes. It draws nothing: the app below stays in view. + * + * The result for a caller that started it for one: `RESULT_OK` with + * [Requests.EXTRA_NOTE_ID], or `RESULT_CANCELED` with [Requests.EXTRA_ERROR]. + */ +class AddActivity : Activity() { + + private lateinit var store: Store + private var pending: MineRequest? = null + private var addedNoteId: Long? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + store = Store(this) + val request = Requests.fromIntent(intent) + if (request == null || (request.isEmpty && request.image == null)) { + toast(getString(R.string.no_text)) + finishWith(null, "empty") + return + } + if (AnkiClient.packageName(this) == null) { + toast(getString(R.string.anki_missing)) + startActivity(Intent(this, MainActivity::class.java)) + finishWith(null, "no_ankidroid") + return + } + if (!AnkiClient.hasPermission(this)) { + pending = request + requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION) + return + } + start(request) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + val request = pending ?: return + pending = null + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + start(request) + } else { + toast(getString(R.string.anki_no_permission)) + finishWith(null, "no_permission") + } + } + + private fun start(request: MineRequest) { + val image = request.image + if (request.isEmpty && image != null) { + // A picture alone: for the last card. + thread { + val result = runCatching { Miner(this).attachPicture(image) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) } + runOnUiThread { report(result) } + } + return + } + thread { + val plan = runCatching { Miner(this).prepare(request) }.getOrElse { e -> + runOnUiThread { report(Miner.Result.Failed(e.message ?: e.toString())) } + return@thread + } + runOnUiThread { + if (isFinishing) return@runOnUiThread + if (request.confirm ?: store.confirm) confirm(plan) else commit(plan, plan.entries.firstOrNull()) + } + } + } + + /** Shows the sentence and the terms of the dictionary; the user picks one and adds. */ + private fun confirm(plan: Miner.Plan) { + var chosen = 0 + val media = getString( + R.string.confirm_media, + yesNo(plan.sentenceAudio != null), yesNo(plan.image != null), yesNo(plan.entries.firstOrNull()?.audio != null), + ) + val builder = AlertDialog.Builder(this) + .setTitle(plan.sentence ?: plan.selection ?: getString(R.string.confirm_title)) + .setPositiveButton(R.string.confirm_add) { _, _ -> commit(plan, plan.entries.getOrNull(chosen)) } + .setNegativeButton(R.string.confirm_cancel) { _, _ -> finishWith(null, "cancelled") } + .setOnCancelListener { finishWith(null, "cancelled") } + if (plan.entries.isEmpty()) { + builder.setMessage((plan.selection ?: getString(R.string.confirm_sentence_only)) + "\n\n" + media) + } else { + val labels = plan.entries.map { it.headword + "\n" + Sentences.unescape(Sentences.stripTags(it.glossary.replace("<br>", " "))).take(80) } + builder.setSingleChoiceItems((labels + media).toTypedArray(), 0) { dialog, i -> + // The last line says what media the card has: it is not a choice. + if (i < labels.size) chosen = i else (dialog as AlertDialog).listView.setItemChecked(chosen, true) + } + } + builder.show() + } + + private fun commit(plan: Miner.Plan, entry: DictionaryClient.Entry?) { + thread { + val result = runCatching { Miner(this).add(plan, entry) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) } + runOnUiThread { report(result) } + } + } + + private fun report(result: Miner.Result) { + when (result) { + is Miner.Result.Added -> { + toast(if (result.expression.isEmpty()) getString(R.string.added_sentence) else getString(R.string.added, result.expression)) + if (store.openDictionary && result.expression.isNotEmpty() && DictionaryClient.installed(this)) { + addedNoteId = result.noteId + openDictionary(result.expression) + } else { + finishWith(result.noteId, null) + } + } + is Miner.Result.Duplicate -> { + toast(getString(R.string.duplicate, result.expression)) + finishWith(null, "duplicate") + } + Miner.Result.Attached -> { + toast(getString(R.string.picture_attached)) + finishWith(null, null) + } + is Miner.Result.Failed -> { + toast(getString(R.string.failed, result.why)) + finishWith(null, result.why) + } + } + } + + /** Opens the pop-up of SubRead Dictionary over the app, and closes when it closes. */ + @Suppress("DEPRECATION") // The result API of AndroidX would bring AppCompat in for one call. + private fun openDictionary(text: String) { + val intent = Intent(DICTIONARY_LOOKUP).setPackage(DictionaryClient.PACKAGE).putExtra(Intent.EXTRA_TEXT, text) + runCatching { startActivityForResult(intent, DICTIONARY) }.onFailure { finishWith(addedNoteId, null) } + } + + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == DICTIONARY) finishWith(addedNoteId, null) + } + + private fun finishWith(noteId: Long?, error: String?) { + if (noteId != null) { + setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, noteId)) + } else { + setResult(RESULT_CANCELED, Intent().putExtra(Requests.EXTRA_ERROR, error.orEmpty())) + } + finish() + } + + private fun yesNo(value: Boolean) = getString(if (value) R.string.yes else R.string.no) + + private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + + companion object { + private const val PERMISSION = 1 + private const val DICTIONARY = 2 + const val DICTIONARY_LOOKUP = "space.subread.dictionary.LOOKUP" + } +} diff --git a/app/src/main/kotlin/space/subread/anki/AnkiClient.kt b/app/src/main/kotlin/space/subread/anki/AnkiClient.kt new file mode 100644 index 0000000..851043c --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/AnkiClient.kt @@ -0,0 +1,109 @@ +package space.subread.anki + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.ichi2.anki.api.AddContentApi +import com.ichi2.anki.api.NoteInfo +import space.subread.anki.core.Fields +import space.subread.anki.core.NoteType +import space.subread.anki.core.Source +import java.io.File + +/** The AnkiDroid API: the deck, the note type, the media files and the notes. */ +class AnkiClient(private val context: Context) { + + private val api = AddContentApi(context) + private val store = Store(context) + + /** The decks of the collection, by id. Empty when AnkiDroid does not answer. */ + fun decks(): Map<Long, String> = runCatching { api.deckList }.getOrNull().orEmpty() + + /** The note types of the collection, by id. */ + fun models(): Map<Long, String> = runCatching { api.modelList }.getOrNull().orEmpty() + + /** The fields of a note type, in order. Empty when the note type is gone. */ + fun fields(modelId: Long): List<String> = runCatching { api.getFieldList(modelId) }.getOrNull()?.toList().orEmpty() + + /** The deck the cards go to. The chosen one, else "SubRead", made when it is not there. */ + fun ensureDeck(): Long { + val decks = decks() + val chosen = store.deckId + if (chosen != Store.NONE && decks.containsKey(chosen)) return chosen + val id = decks.entries.firstOrNull { it.value == DECK_NAME }?.key + ?: api.addNewDeck(DECK_NAME) + ?: throw IllegalStateException("AnkiDroid did not make the deck") + store.deckId = id + return id + } + + /** The note type of the cards. The chosen one, else the app's own, made when it is not there. */ + fun ensureModel(deckId: Long): Long { + val models = models() + val chosen = store.modelId + if (chosen != Store.NONE && models.containsKey(chosen)) return chosen + val id = models.entries.firstOrNull { it.value == NoteType.NAME }?.key + ?: api.addNewCustomModel( + NoteType.NAME, + NoteType.FIELDS.toTypedArray(), + NoteType.CARD_NAMES.toTypedArray(), + NoteType.QUESTION_FORMATS.toTypedArray(), + NoteType.ANSWER_FORMATS.toTypedArray(), + NoteType.CSS, + deckId, + 0, + ) + ?: throw IllegalStateException("AnkiDroid did not make the note type") + store.modelId = id + store.mapping = NoteType.MAPPING + return id + } + + /** What fills each field of [modelId]: the mapping the user set, else a guess from the names. */ + fun mapping(modelId: Long, fields: List<String>): Map<String, Source> { + val set = store.mapping + return if (set.isNotEmpty() && set.keys.containsAll(fields)) set else Fields.guess(fields) + } + + /** + * Copies a file into the media folder of AnkiDroid and returns its name there. [kind] is + * `audio` or `image`. AnkiDroid reads the file through [MediaProvider], so the read is + * granted to it for the copy. + */ + fun addMedia(file: File, kind: String): String? { + val uri = Media.uriFor(context, file) + val anki = packageName(context) ?: return null + context.grantUriPermission(anki, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + try { + val stem = file.nameWithoutExtension + val formatted = api.addMediaFromUri(uri, stem, kind) ?: return null + // The API answers with the field text, `[sound:name]` or `<img src="name" />`: take the name. + return Regex("\\[sound:(.+)]|src=\"([^\"]+)\"").find(formatted)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + } finally { + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + /** True when a note of [modelId] has [key] as its first field. */ + fun isDuplicate(modelId: Long, key: String): Boolean = + key.isNotEmpty() && runCatching { api.findDuplicateNotes(modelId, key) }.getOrNull().orEmpty().isNotEmpty() + + /** Adds a note. Null when AnkiDroid refused it. */ + fun addNote(modelId: Long, deckId: Long, fields: Array<String>, tags: Set<String>): Long? = + api.addNote(modelId, deckId, fields, tags) + + fun note(noteId: Long): NoteInfo? = runCatching { api.getNote(noteId) }.getOrNull() + + fun updateFields(noteId: Long, fields: Array<String>): Boolean = runCatching { api.updateNoteFields(noteId, fields) }.getOrDefault(false) + + companion object { + const val DECK_NAME = "SubRead" + const val PERMISSION = AddContentApi.READ_WRITE_PERMISSION + + /** The package of AnkiDroid, or null when it is not installed. */ + fun packageName(context: Context): String? = AddContentApi.getAnkiDroidPackageName(context) + + fun hasPermission(context: Context): Boolean = + context.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/kotlin/space/subread/anki/AudioCut.kt b/app/src/main/kotlin/space/subread/anki/AudioCut.kt new file mode 100644 index 0000000..13f4c0d --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/AudioCut.kt @@ -0,0 +1,139 @@ +package space.subread.anki + +import android.content.Context +import android.media.AudioFormat +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import androidx.core.net.toUri +import java.io.File +import java.nio.ByteOrder + +/** + * Cuts a clip out of an audio file: the decoder gives the PCM of the range, [AacEncoder] + * writes it. The file can be a `content://` Uri, an `http(s)://` URL (the extractor reads + * the range alone, with range requests), or a `data:` Uri. + */ +object AudioCut { + + private const val TIMEOUT_US = 10_000L + + /** The clip from [startMs] to [endMs] of [source], as AAC in [out]. Null when nothing could be decoded. */ + fun cut(context: Context, source: String, startMs: Long, endMs: Long, out: File): File? { + if (endMs <= startMs) return null + val extractor = MediaExtractor() + var temp: File? = null + try { + val uri = source.toUri() + when (uri.scheme?.lowercase()) { + "http", "https" -> extractor.setDataSource(source) + "content" -> extractor.setDataSource(context, uri, null) + "data" -> { + val bytes = Media.bytes(context, source) ?: return null + temp = File.createTempFile("cut", ".bin", context.cacheDir).apply { writeBytes(bytes) } + extractor.setDataSource(temp.path) + } + else -> return null + } + val track = (0 until extractor.trackCount).firstOrNull { + extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true + } ?: return null + val format = extractor.getTrackFormat(track) + format.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT) + extractor.selectTrack(track) + extractor.seekTo(startMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC) + val decoded = decode(extractor, format, startMs * 1000, endMs * 1000) ?: return null + val (pcm, sampleRate, channels) = decoded + // Less than a tenth of a second is no sentence: the range was outside the file. + if (pcm.size < sampleRate / 10 * channels) return null + AacEncoder.encode(pcm, sampleRate, channels, out) + return out + } catch (e: Exception) { + return null + } finally { + extractor.release() + temp?.delete() + } + } + + /** The PCM of the range, with the sample rate and the channels that the decoder gave. */ + private fun decode(extractor: MediaExtractor, format: MediaFormat, startUs: Long, endUs: Long): Triple<ShortArray, Int, Int>? { + val mime = format.getString(MediaFormat.KEY_MIME) ?: return null + val decoder = MediaCodec.createDecoderByType(mime) + decoder.configure(format, null, null, 0) + decoder.start() + var sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + val pcm = Pcm() + var inputDone = false + var outputDone = false + val info = MediaCodec.BufferInfo() + try { + while (!outputDone) { + if (!inputDone) { + val index = decoder.dequeueInputBuffer(TIMEOUT_US) + if (index >= 0) { + val buffer = decoder.getInputBuffer(index)!! + val size = extractor.readSampleData(buffer, 0) + val time = extractor.sampleTime + if (size < 0 || time > endUs) { + decoder.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + inputDone = true + } else { + decoder.queueInputBuffer(index, 0, size, time, 0) + extractor.advance() + } + } + } + val index = decoder.dequeueOutputBuffer(info, TIMEOUT_US) + when { + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + val changed = decoder.outputFormat + sampleRate = changed.getInteger(MediaFormat.KEY_SAMPLE_RATE) + channels = changed.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + } + index >= 0 -> { + if (info.size > 0) { + val buffer = decoder.getOutputBuffer(index)!! + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + val shorts = buffer.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() + val frames = shorts.remaining() / channels + // The part of this buffer that is inside the clip. + val bufferStartUs = info.presentationTimeUs + val bufferEndUs = bufferStartUs + frames * 1_000_000L / sampleRate + val first = if (bufferStartUs >= startUs) 0 else ((startUs - bufferStartUs) * sampleRate / 1_000_000L).toInt().coerceIn(0, frames) + val last = if (bufferEndUs <= endUs) frames else ((endUs - bufferStartUs) * sampleRate / 1_000_000L).toInt().coerceIn(0, frames) + if (last > first) { + shorts.position(first * channels) + val chunk = ShortArray((last - first) * channels) + shorts.get(chunk) + pcm.add(chunk) + } + } + decoder.releaseOutputBuffer(index, false) + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outputDone = true + } + } + } + } finally { + runCatching { decoder.stop() } + decoder.release() + } + return Triple(pcm.toArray(), sampleRate, channels) + } + + /** A growing array of samples. */ + private class Pcm { + private var data = ShortArray(1 shl 16) + private var size = 0 + + fun add(chunk: ShortArray) { + if (size + chunk.size > data.size) data = data.copyOf(maxOf(data.size * 2, size + chunk.size)) + System.arraycopy(chunk, 0, data, size, chunk.size) + size += chunk.size + } + + fun toArray(): ShortArray = data.copyOf(size) + } +} diff --git a/app/src/main/kotlin/space/subread/anki/CaptureActivity.kt b/app/src/main/kotlin/space/subread/anki/CaptureActivity.kt new file mode 100644 index 0000000..168525d --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/CaptureActivity.kt @@ -0,0 +1,61 @@ +package space.subread.anki + +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast + +/** + * Asks Android for the capture and starts [CaptureService] with the answer. It shows nothing + * of its own: the dialog of Android is the whole screen. + */ +class CaptureActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val wanted = ArrayList<String>() + if (CaptureService.canCaptureAudio && !granted(android.Manifest.permission.RECORD_AUDIO)) wanted += android.Manifest.permission.RECORD_AUDIO + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !granted(android.Manifest.permission.POST_NOTIFICATIONS)) { + wanted += android.Manifest.permission.POST_NOTIFICATIONS + } + if (wanted.isEmpty()) ask() else requestPermissions(wanted.toTypedArray(), PERMISSIONS) + } + + private fun granted(permission: String) = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + // Without the microphone permission the capture still takes pictures. + if (CaptureService.canCaptureAudio && !granted(android.Manifest.permission.RECORD_AUDIO)) { + Toast.makeText(this, R.string.capture_no_record, Toast.LENGTH_LONG).show() + } + ask() + } + + @Suppress("DEPRECATION") // The result API of AndroidX would bring AppCompat in for one dialog. + private fun ask() { + val manager = getSystemService(MediaProjectionManager::class.java) + startActivityForResult(manager.createScreenCaptureIntent(), CONSENT) + } + + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == CONSENT) { + if (resultCode == RESULT_OK && data != null) { + CaptureService.start(this, resultCode, data) + } else { + Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_LONG).show() + } + finish() + } + } + + companion object { + private const val PERMISSIONS = 1 + private const val CONSENT = 2 + } +} diff --git a/app/src/main/kotlin/space/subread/anki/CaptureService.kt b/app/src/main/kotlin/space/subread/anki/CaptureService.kt new file mode 100644 index 0000000..485534a --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/CaptureService.kt @@ -0,0 +1,268 @@ +package space.subread.anki + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.graphics.PixelFormat +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.media.Image +import android.media.ImageReader +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.util.DisplayMetrics +import android.view.Display +import androidx.core.content.IntentCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import space.subread.anki.core.PcmRing +import java.io.File +import kotlin.concurrent.thread + +/** + * Keeps the last minute and a half of the sound of the device and the last picture of the + * screen, while the user has the capture on. A card takes its clip and its picture from here. + * + * Android gives the sound of other apps to a media projection alone, and only since Android + * 10. The projection needs the consent of the user each time it starts, and Android shows it + * in the status bar. The service is a foreground service, as Android demands for a projection. + * + * The clock of the ring is `SystemClock.elapsedRealtimeNanos()`, the clock of the media + * sessions: a report of the player maps its media times straight onto the ring. + */ +class CaptureService : Service() { + + private val handler = Handler(Looper.getMainLooper()) + private var projection: MediaProjection? = null + private var record: AudioRecord? = null + private var display: VirtualDisplay? = null + private var reader: ImageReader? = null + + /** The newest picture of the screen. Kept open until a newer one comes. */ + private var latest: Image? = null + private val imageLock = Any() + + private val ring = PcmRing(SAMPLE_RATE, 1, SAMPLE_RATE * SECONDS) + private val ringLock = Any() + + @Volatile + private var running = false + + /** True when the sound of the player is captured; false on Android 9 or without the permission. */ + var hasAudio = false + private set + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null || intent.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } + val code = intent.getIntExtra(EXTRA_CODE, 0) + val data = IntentCompat.getParcelableExtra(intent, EXTRA_DATA, Intent::class.java) + if (data == null || projection != null) { + stopSelf() + return START_NOT_STICKY + } + startInForeground() + val manager = getSystemService(MediaProjectionManager::class.java) + val projection = runCatching { manager.getMediaProjection(code, data) }.getOrNull() + if (projection == null) { + stopSelf() + return START_NOT_STICKY + } + this.projection = projection + // Android 14 demands the callback before the display, and stops the projection from + // the status bar or the lock screen: the service goes with it. + projection.registerCallback(object : MediaProjection.Callback() { + override fun onStop() = stopSelf() + }, handler) + running = true + instance = this + startAudio(projection) + startScreen(projection) + return START_NOT_STICKY + } + + private fun startInForeground() { + val channel = NotificationChannel(CHANNEL, getString(R.string.capture_channel), NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + val stop = PendingIntent.getService( + this, 0, Intent(this, CaptureService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE) + val notification = Notification.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.icon) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.capture_notification)) + .setContentIntent(open) + .addAction(Notification.Action.Builder(null, getString(R.string.capture_stop), stop).build()) + .setOngoing(true) + .build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun startAudio(projection: MediaProjection) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) return + val config = AudioPlaybackCaptureConfiguration.Builder(projection) + .addMatchingUsage(AudioAttributes.USAGE_MEDIA) + .addMatchingUsage(AudioAttributes.USAGE_GAME) + .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) + .build() + val minimum = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + val record = runCatching { + AudioRecord.Builder() + .setAudioPlaybackCaptureConfig(config) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(SAMPLE_RATE) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .build(), + ) + .setBufferSizeInBytes(maxOf(minimum, SAMPLE_RATE) * 2) + .build() + }.getOrNull() ?: return + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + return + } + this.record = record + hasAudio = true + record.startRecording() + thread(name = "capture-audio") { + val buffer = ShortArray(SAMPLE_RATE / 10) + while (running) { + val n = record.read(buffer, 0, buffer.size) + if (n <= 0) continue + val now = SystemClock.elapsedRealtimeNanos() + synchronized(ringLock) { ring.write(buffer, n, now) } + } + } + } + + private fun startScreen(projection: MediaProjection) { + val metrics = DisplayMetrics() + val display = getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) ?: return + @Suppress("DEPRECATION") // The size of the whole screen: a service has no window to measure. + display.getRealMetrics(metrics) + val reader = ImageReader.newInstance(metrics.widthPixels, metrics.heightPixels, PixelFormat.RGBA_8888, 3) + reader.setOnImageAvailableListener({ r -> + val image = runCatching { r.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener + synchronized(imageLock) { + latest?.close() + latest = image + } + }, handler) + this.reader = reader + this.display = runCatching { + projection.createVirtualDisplay( + "SubReadAnki", metrics.widthPixels, metrics.heightPixels, metrics.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader.surface, null, handler, + ) + }.getOrNull() + } + + /** + * The sound between two moments of the ring clock, as AAC in [out]. Null when the ring + * has none of it, or less than a fifth of a second. + */ + fun clip(fromNanos: Long, toNanos: Long, out: File): File? { + val pcm = synchronized(ringLock) { ring.read(fromNanos, toNanos) } + if (pcm.size < SAMPLE_RATE / 5) return null + return runCatching { AacEncoder.encode(pcm, SAMPLE_RATE, 1, out); out }.getOrNull() + } + + /** The newest picture of the screen, as a JPEG in [out]. Null when there is none yet. */ + fun screenshot(out: File): File? { + val bitmap = synchronized(imageLock) { + val image = latest ?: return null + val plane = image.planes[0] + val stride = plane.rowStride / plane.pixelStride + val full = createBitmap(stride, image.height) + full.copyPixelsFromBuffer(plane.buffer.also { it.rewind() }) + if (stride == image.width) full else Bitmap.createBitmap(full, 0, 0, image.width, image.height) + } + val longest = maxOf(bitmap.width, bitmap.height) + val scaled = if (longest <= MAX_SIDE) bitmap else { + val scale = MAX_SIDE.toFloat() / longest + bitmap.scale((bitmap.width * scale).toInt(), (bitmap.height * scale).toInt()) + } + return runCatching { + out.outputStream().use { scaled.compress(Bitmap.CompressFormat.JPEG, 85, it) } + out + }.getOrNull() + } + + override fun onDestroy() { + running = false + instance = null + record?.let { runCatching { it.stop() }; it.release() } + record = null + display?.release() + display = null + reader?.close() + reader = null + synchronized(imageLock) { + latest?.close() + latest = null + } + projection?.stop() + projection = null + super.onDestroy() + } + + companion object { + const val SAMPLE_RATE = 44_100 + /** How much sound the ring keeps. */ + const val SECONDS = 90 + /** The longest side of the picture on the card, in pixels. */ + const val MAX_SIDE = 1600 + const val CHANNEL = "capture" + const val NOTIFICATION_ID = 1 + const val ACTION_STOP = "space.subread.anki.action.STOP_CAPTURE" + const val EXTRA_CODE = "code" + const val EXTRA_DATA = "data" + + /** The service while it runs; null when the capture is off. */ + @Volatile + var instance: CaptureService? = null + private set + + /** True when this Android can capture the sound of other apps. */ + val canCaptureAudio: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + /** Starts the capture with the consent that Android gave [CaptureActivity]. */ + fun start(context: Context, code: Int, data: Intent) { + val intent = Intent(context, CaptureService::class.java).putExtra(EXTRA_CODE, code).putExtra(EXTRA_DATA, data) + context.startForegroundService(intent) + } + + fun stop(context: Context) { + context.startService(Intent(context, CaptureService::class.java).setAction(ACTION_STOP)) + } + } +} diff --git a/app/src/main/kotlin/space/subread/anki/CaptureTile.kt b/app/src/main/kotlin/space/subread/anki/CaptureTile.kt new file mode 100644 index 0000000..bff88f4 --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/CaptureTile.kt @@ -0,0 +1,35 @@ +package space.subread.anki + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService + +/** The tile in the quick settings: one tap starts the capture, one tap stops it. */ +class CaptureTile : TileService() { + + override fun onStartListening() { + val tile = qsTile ?: return + tile.state = if (CaptureService.instance != null) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.updateTile() + } + + // The Intent form is the only one before Android 14; the version check keeps it there. + @SuppressLint("StartActivityAndCollapseDeprecated") + override fun onClick() { + if (CaptureService.instance != null) { + CaptureService.stop(this) + onStartListening() + return + } + val intent = Intent(this, CaptureActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } +} diff --git a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt new file mode 100644 index 0000000..351ac7e --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt @@ -0,0 +1,86 @@ +package space.subread.anki + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import androidx.core.net.toUri + +/** + * Asks SubRead Dictionary for the terms of a text, through its content provider + * `content://space.subread.dictionary.lookup/lookup?text=...`. One row for each term of each + * dictionary, the longest term first. The rows of one term and reading make one [Entry]. + */ +object DictionaryClient { + + const val PACKAGE = "space.subread.dictionary" + const val AUTHORITY = "space.subread.dictionary.lookup" + + /** One term: the entries of every dictionary for it, joined. */ + data class Entry( + val expression: String, + val reading: String, + /** How many characters of the text the term covers. */ + val length: Int, + /** HTML: the name of each dictionary and its glossary. */ + val glossary: String, + val frequency: String, + val pitch: String, + /** Where the audio of the word is, or null. */ + val audio: Uri?, + ) { + val headword: String get() = if (reading.isEmpty() || reading == expression) expression else "$expression【$reading】" + } + + fun installed(context: Context): Boolean = runCatching { + context.packageManager.getPackageInfo(PACKAGE, 0) + }.isSuccess + + /** True when the installed dictionary has the provider: the versions before it do not. */ + fun answers(context: Context): Boolean = + context.packageManager.resolveContentProvider(AUTHORITY, 0) != null + + /** The terms at the start of [text]. Empty when the dictionary is not there or has no term. */ + fun lookup(context: Context, text: String): List<Entry> { + if (text.isEmpty() || !answers(context)) return emptyList() + val uri = "content://$AUTHORITY/lookup".toUri().buildUpon().appendQueryParameter("text", text).build() + val cursor = runCatching { context.contentResolver.query(uri, null, null, null, null) }.getOrNull() ?: return emptyList() + data class Row(val expression: String, val reading: String, val length: Int, val dictionary: String, val glossary: String, val frequency: String, val pitch: String, val audio: String?) + val rows = cursor.use { c -> + buildList { + while (c.moveToNext()) add( + Row( + c.text("expression") ?: continue, c.text("reading").orEmpty(), c.long("length")?.toInt() ?: 0, + c.text("dictionary").orEmpty(), c.text("glossary").orEmpty(), c.text("frequency").orEmpty(), + c.text("pitch").orEmpty(), c.text("audio"), + ), + ) + } + } + return rows.groupBy { it.expression to it.reading }.map { (key, group) -> + val first = group.first() + Entry( + expression = key.first, + reading = key.second, + length = first.length, + glossary = group.joinToString("<br>") { row -> + if (row.dictionary.isEmpty()) row.glossary else "<i>${escape(row.dictionary)}</i><br>${row.glossary}" + }, + frequency = group.map { it.frequency }.firstOrNull { it.isNotEmpty() }.orEmpty(), + pitch = group.map { it.pitch }.firstOrNull { it.isNotEmpty() }.orEmpty(), + audio = first.audio?.toUri(), + ) + } + } + + private fun escape(text: String) = text.replace("&", "&").replace("<", "<").replace(">", ">") + + private fun Cursor.text(column: String): String? { + val i = getColumnIndex(column) + return if (i < 0 || isNull(i)) null else getString(i) + } + + private fun Cursor.long(column: String): Long? { + val i = getColumnIndex(column) + return if (i < 0 || isNull(i)) null else getLong(i) + } +} diff --git a/app/src/main/kotlin/space/subread/anki/MainActivity.kt b/app/src/main/kotlin/space/subread/anki/MainActivity.kt new file mode 100644 index 0000000..8d3becd --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/MainActivity.kt @@ -0,0 +1,333 @@ +package space.subread.anki + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.text.InputType +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import androidx.core.net.toUri +import space.subread.anki.core.Fields +import space.subread.anki.core.MineRequest +import space.subread.anki.core.NoteType +import space.subread.anki.core.Source +import kotlin.concurrent.thread + +/** + * The one screen of the app: AnkiDroid, the deck and the note type, the SubRead apps, the + * capture, the options, and a field to try a card. Each part is on the screen from the + * start, so that the user sees what the app does before a button is pressed. + */ +class MainActivity : Activity() { + + private lateinit var store: Store + private lateinit var content: LinearLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + store = Store(this) + content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(20), dp(20), dp(20)) + setBackgroundColor(Color.WHITE) + } + setContentView(ScrollView(this).apply { + fitsSystemWindows = true + setBackgroundColor(Color.WHITE) + addView(content) + }) + } + + /** The user comes back from AnkiDroid or from a dialog of Android here, so the state is read again. */ + override fun onResume() { + super.onResume() + draw() + } + + private fun draw() { + content.removeAllViews() + title(getString(R.string.app_name)) + note(getString(R.string.about)) + note(getString(R.string.privacy)) + + val anki = AnkiClient.packageName(this) + val allowed = anki != null && AnkiClient.hasPermission(this) + when { + anki == null -> step(R.string.step_anki, getString(R.string.anki_missing), getString(R.string.anki_install)) { open(ANKIDROID_INSTALL) } + !allowed -> step(R.string.step_anki, getString(R.string.anki_no_permission), getString(R.string.anki_allow)) { + requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION) + } + else -> step(R.string.step_anki, getString(R.string.anki_ok), null, done = true) {} + } + + step(R.string.step_deck, getString(R.string.deck_current, deckName()), if (allowed) getString(R.string.deck_choose) else null) { chooseDeck() } + note(modelName(), top = 12) + if (allowed) row(button(getString(R.string.model_choose)) { chooseModel() }, button(getString(R.string.fields)) { editFields() }) + note(getString(R.string.fields_why), top = 4) + + val overlay = installed(OverlayClient.PACKAGE) + val dictionary = DictionaryClient.installed(this) + step(R.string.step_subread, getString(if (overlay) R.string.overlay_ok else R.string.overlay_missing), null) {} + note( + getString( + when { + !dictionary -> R.string.dictionary_missing + !DictionaryClient.answers(this) -> R.string.dictionary_old + else -> R.string.dictionary_ok + }, + ), + ) + row( + if (overlay) button(getString(R.string.open_overlay)) { launch(OverlayClient.PACKAGE) } + else button(getString(R.string.install_overlay)) { open(OVERLAY_INSTALL) }, + if (dictionary) button(getString(R.string.open_dictionary)) { launch(DictionaryClient.PACKAGE) } + else button(getString(R.string.install_dictionary)) { open(DICTIONARY_INSTALL) }, + ) + + val capturing = CaptureService.instance != null + step( + R.string.step_capture, + getString(R.string.capture_why) + if (CaptureService.canCaptureAudio) "" else "\n\n" + getString(R.string.capture_no_audio), + getString(if (capturing) R.string.capture_stop else R.string.capture_start), + done = capturing, + ) { + if (capturing) CaptureService.stop(this) else startActivity(Intent(this, CaptureActivity::class.java)) + content.postDelayed({ draw() }, 500) + } + note(getString(R.string.pad), top = 12) + numberField(store.padMs) { store.padMs = it } + + step(R.string.step_options, getString(if (store.confirm) R.string.confirm_on else R.string.confirm_off), getString(R.string.toggle)) { + store.confirm = !store.confirm + draw() + } + note(getString(if (store.skipDuplicates) R.string.duplicates_skip else R.string.duplicates_add), top = 12) + content.addView(button(getString(R.string.toggle)) { store.skipDuplicates = !store.skipDuplicates; draw() }, narrow()) + note(getString(if (store.openDictionary) R.string.open_dictionary_on else R.string.open_dictionary_off), top = 12) + content.addView(button(getString(R.string.toggle)) { store.openDictionary = !store.openDictionary; draw() }, narrow()) + note(getString(R.string.tags), top = 12) + textField(store.tags) { store.tags = it } + + step(R.string.step_try, "", null) {} + val tryField = EditText(this).apply { + hint = getString(R.string.try_hint) + setTextColor(Color.BLACK) + } + content.addView(tryField, wide()) + content.addView(button(getString(R.string.try_add)) { + val text = tryField.text.toString().trim() + if (text.isNotEmpty()) startActivity(Requests.toIntent(MineRequest(expression = text)).setClass(this, AddActivity::class.java)) + }, narrow()) + + if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide(top = 24)) + } + + private fun deckName(): String { + val id = store.deckId + if (id == Store.NONE) return AnkiClient.DECK_NAME + return AnkiClient(this).decks()[id] ?: AnkiClient.DECK_NAME + } + + private fun modelName(): String { + val id = store.modelId + if (id == Store.NONE) return getString(R.string.model_own, NoteType.NAME) + val name = AnkiClient(this).models()[id] ?: return getString(R.string.model_own, NoteType.NAME) + return getString(R.string.model_current, name) + } + + private fun chooseDeck() { + thread { + val decks = AnkiClient(this).decks().entries.sortedBy { it.value } + runOnUiThread { + val names = decks.map { it.value } + getString(R.string.deck_new) + AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which -> + val deck = decks.getOrNull(which) + if (deck != null) { + store.deckId = deck.key + draw() + } else { + newDeck() + } + }.show() + } + } + } + + private fun newDeck() { + val field = EditText(this).apply { hint = getString(R.string.deck_new_name) } + AlertDialog.Builder(this).setView(field).setPositiveButton(R.string.save) { _, _ -> + val name = field.text.toString().trim() + if (name.isEmpty()) return@setPositiveButton + thread { + val id = runCatching { AnkiClient(this).run { decks().entries.firstOrNull { it.value == name }?.key ?: addNewDeckOrNull(name) } }.getOrNull() + runOnUiThread { + if (id != null) store.deckId = id else toast(getString(R.string.failed, name)) + draw() + } + } + }.setNegativeButton(R.string.confirm_cancel, null).show() + } + + private fun AnkiClient.addNewDeckOrNull(name: String): Long? = runCatching { + com.ichi2.anki.api.AddContentApi(this@MainActivity).addNewDeck(name) + }.getOrNull() + + private fun chooseModel() { + thread { + val models = AnkiClient(this).models().entries.sortedBy { it.value } + runOnUiThread { + val names = listOf(getString(R.string.model_own_choice)) + models.map { it.value } + AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which -> + val model = models.getOrNull(which - 1) + store.modelId = model?.key ?: Store.NONE + // A new note type gets a fresh guess of its fields. + store.mapping = if (model == null) NoteType.MAPPING else emptyMap() + draw() + }.show() + } + } + } + + /** One dialog with the fields; a tap on a field opens the list of what can fill it. */ + private fun editFields() { + thread { + val anki = AnkiClient(this) + val modelId = runCatching { anki.ensureModel(anki.ensureDeck()) }.getOrNull() + val fields = modelId?.let { anki.fields(it) }.orEmpty() + runOnUiThread { + if (modelId == null || fields.isEmpty()) { + toast(getString(R.string.anki_no_permission)) + return@runOnUiThread + } + val mapping = LinkedHashMap(anki.mapping(modelId, fields).let { m -> fields.associateWith { m[it] ?: Source.NONE } }) + showFields(fields, mapping) + } + } + } + + private fun showFields(fields: List<String>, mapping: LinkedHashMap<String, Source>) { + val labels = fields.map { getString(R.string.field_source, it, mapping[it]!!.name) } + AlertDialog.Builder(this) + .setTitle(R.string.fields) + .setItems(labels.toTypedArray()) { _, which -> + val field = fields[which] + val sources = Source.entries + AlertDialog.Builder(this).setTitle(field).setItems(sources.map { it.name }.toTypedArray()) { _, i -> + mapping[field] = sources[i] + showFields(fields, mapping) + }.show() + } + .setPositiveButton(R.string.save) { _, _ -> + store.mapping = mapping + toast(getString(R.string.saved)) + } + .setNegativeButton(R.string.confirm_cancel, null) + .show() + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == PERMISSION && grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) { + toast(getString(R.string.anki_no_permission)) + } + draw() + } + + private fun installed(packageName: String): Boolean = + runCatching { packageManager.getPackageInfo(packageName, 0) }.isSuccess + + private fun launch(packageName: String) { + packageManager.getLaunchIntentForPackage(packageName)?.let { runCatching { startActivity(it) } } + } + + private fun open(link: String) { + runCatching { startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) } + } + + private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() + + // The screen is built in code: a few rows do not need a layout file each. + + private fun title(value: String) = content.addView(TextView(this).apply { + text = value + textSize = 26f + setTypeface(typeface, Typeface.BOLD) + setTextColor(Color.BLACK) + }) + + private fun note(value: String, top: Int = 8) = content.addView(TextView(this).apply { + text = value + textSize = 15f + setTextColor(Color.BLACK) + }, wide(top)) + + @SuppressLint("SetTextI18n") + private fun step(name: Int, detail: String, action: String?, done: Boolean = false, onAction: () -> Unit) { + content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) }) + content.addView(TextView(this).apply { + text = getString(name) + if (done) " ✓" else "" + textSize = 18f + setTypeface(typeface, Typeface.BOLD) + setTextColor(Color.BLACK) + }, wide(top = 12)) + if (detail.isNotEmpty()) note(detail, top = 4) + if (action != null) content.addView(button(action, onAction), narrow()) + } + + private fun textField(value: String, onSave: (String) -> Unit) { + val field = EditText(this).apply { + setText(value) + setTextColor(Color.BLACK) + } + row(field.also { it.layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }, button(getString(R.string.save)) { + onSave(field.text.toString()) + toast(getString(R.string.saved)) + }) + } + + // A count of milliseconds is typed as plain digits, in every locale. + @SuppressLint("SetTextI18n") + private fun numberField(value: Int, onSave: (Int) -> Unit) { + val field = EditText(this).apply { + setText(value.toString()) + inputType = InputType.TYPE_CLASS_NUMBER + setTextColor(Color.BLACK) + } + row(field.also { it.layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }, button(getString(R.string.save)) { + field.text.toString().toIntOrNull()?.let(onSave) + toast(getString(R.string.saved)) + }) + } + + private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } }, wide(top = 4)) + + private fun button(label: String, onClick: () -> Unit) = Button(this).apply { + text = label + isAllCaps = false + setOnClickListener { onClick() } + } + + private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) } + + private fun narrow() = LinearLayout.LayoutParams(-2, -2) + + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() + + private companion object { + const val PERMISSION = 1 + const val ANKIDROID_INSTALL = "https://play.google.com/store/apps/details?id=com.ichi2.anki" + const val OVERLAY_INSTALL = "https://github.com/equwal/subread-overlay/releases/latest" + const val DICTIONARY_INSTALL = "https://github.com/equwal/subread-dictionary/releases/latest" + const val KOFI = "https://ko-fi.com/truex" + } +} diff --git a/app/src/main/kotlin/space/subread/anki/Media.kt b/app/src/main/kotlin/space/subread/anki/Media.kt new file mode 100644 index 0000000..5d15abe --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/Media.kt @@ -0,0 +1,137 @@ +package space.subread.anki + +import android.content.Context +import android.net.Uri +import android.util.Base64 +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** The media files of a card, in the cache, until AnkiDroid has copied them. */ +object Media { + + /** A file bigger than this is refused: a card holds a clip, not a book. */ + const val MAX_BYTES = 30L * 1024 * 1024 + + enum class Kind { AUDIO, IMAGE } + + fun dir(context: Context): File = File(context.cacheDir, "media").apply { mkdirs() } + + fun file(context: Context, name: String): File = File(dir(context), name) + + /** The Uri that AnkiDroid reads the file through. */ + fun uriFor(context: Context, file: File): Uri = + FileProvider.getUriForFile(context, context.packageName + ".files", file) + + /** Deletes the files of earlier cards. AnkiDroid copied them at once; a day is plenty. */ + fun cleanOld(context: Context) { + val limit = System.currentTimeMillis() - 24 * 3600_000L + dir(context).listFiles()?.filter { it.lastModified() < limit }?.forEach { it.delete() } + } + + /** + * Fetches a media file: a `content://` Uri, an `http(s)://` URL, or a `data:` Uri. The + * bytes say what the file is; the name gets that extension. Null when the source cannot + * be read, is empty, or is not a file of [kind]. + */ + fun fetch(context: Context, source: String, stem: String, kind: Kind): File? { + val bytes = runCatching { bytes(context, source) }.getOrNull() ?: return null + if (bytes.isEmpty()) return null + val extension = extension(bytes, kind) ?: return null + val out = file(context, "$stem.$extension") + out.writeBytes(bytes) + return out + } + + /** The bytes of a source. */ + fun bytes(context: Context, source: String): ByteArray? { + val uri = source.toUri() + return when (uri.scheme?.lowercase()) { + "content" -> context.contentResolver.openInputStream(uri)?.use { it.readBytes(MAX_BYTES) } + "http", "https" -> { + val connection = URL(source).openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + try { + if (connection.responseCode != 200) null else connection.inputStream.use { it.readBytes(MAX_BYTES) } + } finally { + connection.disconnect() + } + } + "data" -> { + // data:audio/mpeg;base64,SUQz... + val comma = source.indexOf(',') + if (comma < 0) return null + val head = source.substring(0, comma) + val body = source.substring(comma + 1) + if (head.endsWith(";base64")) Base64.decode(body, Base64.DEFAULT) else Uri.decode(body).toByteArray() + } + // A file path would read the private files of this app for the sender. + else -> null + } + } + + private fun java.io.InputStream.readBytes(limit: Long): ByteArray? { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = read(buffer) + if (n < 0) break + total += n + if (total > limit) return null + out.write(buffer, 0, n) + } + return out.toByteArray() + } + + /** The extension for the bytes of a file of [kind], from its first bytes. Null for an unknown file. */ + fun extension(bytes: ByteArray, kind: Kind): String? { + fun at(offset: Int, text: String) = + bytes.size >= offset + text.length && text.indices.all { bytes[offset + it] == text[it].code.toByte() } + return when (kind) { + Kind.IMAGE -> when { + bytes.size > 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> "jpg" + at(1, "PNG") -> "png" + at(0, "RIFF") && at(8, "WEBP") -> "webp" + at(0, "GIF8") -> "gif" + else -> null + } + Kind.AUDIO -> when { + at(0, "ID3") -> "mp3" + bytes.size > 2 && bytes[0] == 0xFF.toByte() && (bytes[1].toInt() and 0xE0) == 0xE0 -> "mp3" + at(0, "OggS") -> "ogg" + at(0, "RIFF") && at(8, "WAVE") -> "wav" + at(0, "fLaC") -> "flac" + at(4, "ftyp") -> "m4a" + else -> null + } + } + } + + /** The MIME type for an extension of [extension]. AnkiDroid names its copy from it. */ + fun mimeType(extension: String): String? = when (extension.lowercase()) { + "mp3" -> "audio/mpeg" + "m4a" -> "audio/mp4" + "ogg", "oga", "opus" -> "audio/ogg" + "wav" -> "audio/x-wav" + "flac" -> "audio/flac" + "aac" -> "audio/aac" + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "webp" -> "image/webp" + "gif" -> "image/gif" + else -> null + } +} + +/** + * The file provider for AnkiDroid. It names the MIME type itself: AnkiDroid takes the + * extension of its copy from the type, and the map of Android would call an `.m4a` an mp3. + */ +class MediaProvider : FileProvider() { + override fun getType(uri: Uri): String? = + uri.lastPathSegment?.substringAfterLast('.', "")?.let { Media.mimeType(it) } ?: super.getType(uri) +} diff --git a/app/src/main/kotlin/space/subread/anki/Miner.kt b/app/src/main/kotlin/space/subread/anki/Miner.kt new file mode 100644 index 0000000..16f893a --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/Miner.kt @@ -0,0 +1,165 @@ +package space.subread.anki + +import android.content.Context +import android.os.SystemClock +import space.subread.anki.core.Fields +import space.subread.anki.core.MediaNames +import space.subread.anki.core.MineRequest +import space.subread.anki.core.Note +import space.subread.anki.core.PlayerReport +import space.subread.anki.core.Sentences +import space.subread.anki.core.Source +import java.io.File + +/** + * Makes a card from a request: fills what the request does not say from the overlay, the + * dictionary and the capture, then adds it to AnkiDroid. Two steps, so that the user can be + * shown the card in between: [prepare], then [add]. + */ +class Miner(private val context: Context) { + + private val store = Store(context) + + /** A card ready to add. */ + class Plan( + val request: MineRequest, + /** The word as the user selected it, or null for a sentence card. */ + val selection: String?, + val sentence: String?, + /** The terms of the dictionary for the selection, the longest first. */ + val entries: List<DictionaryClient.Entry>, + val sentenceAudio: File?, + val image: File?, + /** The stem of the media files of this card. */ + val stem: String, + ) + + sealed class Result { + class Added(val noteId: Long, val expression: String) : Result() + class Duplicate(val expression: String) : Result() + object Attached : Result() + class Failed(val why: String) : Result() + } + + /** Gathers everything for the card. Slow: the sound is encoded, a URL is fetched. Not on the UI thread. */ + fun prepare(request: MineRequest): Plan { + Media.cleanOld(context) + var selection = request.expression?.trim()?.takeIf { it.isNotEmpty() } + var sentence = request.sentence?.trim()?.takeIf { it.isNotEmpty() } + // A whole sentence in the selection, and no sentence given: a sentence card. + if (selection != null && sentence == null && looksLikeSentence(selection)) { + sentence = selection + selection = null + } + val overlay = OverlayClient.now(context) + val line = overlay?.line + if (sentence == null && line != null && (selection == null || line.text.contains(selection))) sentence = line.text + val stamp = System.currentTimeMillis() + val stem = MediaNames.stem(selection ?: sentence ?: "card", stamp) + val entries = if (selection != null && request.definition == null) DictionaryClient.lookup(context, selection) else emptyList() + val sentenceAudio = sentenceAudio(request, overlay, stem) + val image = request.image?.let { Media.fetch(context, it, stem, Media.Kind.IMAGE) } + ?: CaptureService.instance?.screenshot(Media.file(context, "$stem.jpg")) + return Plan(request, selection, sentence, entries, sentenceAudio, image, stem) + } + + /** + * The sound of the sentence: cut from the file the request names, or fetched whole, or + * taken from the capture. From the capture, the clip is the subtitle line of now, on the + * clock of the player; without a line, the last seconds before the tap. + */ + private fun sentenceAudio(request: MineRequest, overlay: OverlayClient.Now?, stem: String): File? { + val audio = request.audio + if (audio != null) { + val start = request.audioStartMs + val end = request.audioEndMs + return if (start != null && end != null) AudioCut.cut(context, audio, start, end, Media.file(context, "$stem.m4a")) + else Media.fetch(context, audio, "${stem}_sentence", Media.Kind.AUDIO) + } + val capture = CaptureService.instance ?: return null + val now = SystemClock.elapsedRealtimeNanos() + val line = overlay?.line + // A sender that gives the position of its player, and no overlay: the tap is the position. + val report = overlay?.report ?: request.positionMs?.let { PlayerReport(it, now, 1.0, false) } + val cueStart = request.cueStartMs ?: line?.let { it.startMs - it.offsetMs } + val cueEnd = request.cueEndMs ?: line?.let { it.endMs - it.offsetMs } + val range = if (report != null && cueStart != null && cueEnd != null && cueEnd > cueStart) { + report.wallRange(cueStart, cueEnd, store.padMs.toLong()) + } else { + (now - LAST_SECONDS * 1_000_000_000L)..now + } + return capture.clip(range.first, range.last, Media.file(context, "$stem.m4a")) + } + + /** Adds the card to AnkiDroid, with [entry] as the term. Not on the UI thread. */ + fun add(plan: Plan, entry: DictionaryClient.Entry?): Result { + val anki = AnkiClient(context) + val deckId = anki.ensureDeck() + val modelId = anki.ensureModel(deckId) + val fields = anki.fields(modelId) + if (fields.isEmpty()) return Result.Failed("the note type has no field") + val mapping = anki.mapping(modelId, fields) + val request = plan.request + val expression = entry?.expression ?: plan.selection ?: "" + if (store.skipDuplicates && expression.isNotEmpty() && mapping[fields.first()] == Source.EXPRESSION && anki.isDuplicate(modelId, expression)) { + return Result.Duplicate(expression) + } + val sentenceAudio = plan.sentenceAudio?.let { anki.addMedia(it, "audio") }.orEmpty() + val image = plan.image?.let { anki.addMedia(it, "image") }.orEmpty() + val wordAudio = (request.wordAudio ?: entry?.audio?.toString()) + ?.let { Media.fetch(context, it, "${plan.stem}_word", Media.Kind.AUDIO) } + ?.let { anki.addMedia(it, "audio") }.orEmpty() + val note = Note( + expression = expression, + reading = request.reading ?: entry?.reading.orEmpty(), + definition = request.definition ?: entry?.glossary.orEmpty(), + sentence = plan.sentence.orEmpty(), + sentenceAudioFile = sentenceAudio, + wordAudioFile = wordAudio, + imageFile = image, + source = request.source.orEmpty(), + pitch = request.pitch ?: entry?.pitch.orEmpty(), + frequency = request.frequency ?: entry?.frequency.orEmpty(), + selection = plan.selection.orEmpty(), + ) + val values = Fields.render(note, fields, mapping) + val tags = (store.tags.split(' ') + request.tags).map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val noteId = anki.addNote(modelId, deckId, values, tags) ?: return Result.Failed("AnkiDroid refused the note") + store.lastNoteId = noteId + store.lastNoteAt = System.currentTimeMillis() + return Result.Added(noteId, expression) + } + + /** A picture shared on its own goes onto the last card of the hour. Not on the UI thread. */ + fun attachPicture(source: String): Result { + val noteId = store.lastNoteId + if (noteId == Store.NONE || System.currentTimeMillis() - store.lastNoteAt > 3_600_000L) { + return Result.Failed(context.getString(R.string.picture_no_card)) + } + val anki = AnkiClient(context) + val info = anki.note(noteId) ?: return Result.Failed(context.getString(R.string.picture_no_card)) + val modelId = anki.ensureModel(anki.ensureDeck()) + val fields = anki.fields(modelId) + val mapping = anki.mapping(modelId, fields) + val at = fields.indexOfFirst { mapping[it] == Source.IMAGE } + if (at < 0) return Result.Failed("the note type has no picture field") + val file = Media.fetch(context, source, MediaNames.stem("picture", System.currentTimeMillis()), Media.Kind.IMAGE) + ?: return Result.Failed("the picture cannot be read") + val name = anki.addMedia(file, "image") ?: return Result.Failed("AnkiDroid refused the picture") + val values = info.fields.copyOf() + values[at] = Fields.image(name) + return if (anki.updateFields(noteId, values)) Result.Attached else Result.Failed("AnkiDroid refused the change") + } + + companion object { + /** Without a subtitle line, the clip is this much sound before the tap. */ + const val LAST_SECONDS = 8 + + /** The longest selection that is a word. Longer, or with the end of a sentence in it, is a sentence. */ + const val MAX_WORD = 24 + + /** True for a selection that is a sentence, not a word. */ + fun looksLikeSentence(text: String): Boolean = + text.length > MAX_WORD || text.any { Sentences.isTerminator(it) } || text.trim().split(Regex("\\s+")).size > 3 + } +} diff --git a/app/src/main/kotlin/space/subread/anki/OverlayClient.kt b/app/src/main/kotlin/space/subread/anki/OverlayClient.kt new file mode 100644 index 0000000..41501f2 --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/OverlayClient.kt @@ -0,0 +1,89 @@ +package space.subread.anki + +import android.content.Context +import android.database.Cursor +import android.os.SystemClock +import androidx.core.net.toUri +import space.subread.anki.core.PlayerReport + +/** + * Asks SubRead Overlay for the player and the subtitle line of now, through its content + * provider `content://space.subread.overlay.player`. + * + * `/state` is in every version of the overlay. `/line` came with the Anki support: a version + * without it answers the state row for every path, and then the line is unknown. + */ +object OverlayClient { + + const val PACKAGE = "space.subread.overlay" + const val AUTHORITY = "space.subread.overlay.player" + + /** The subtitle line of now. The times are on the clock of the subtitle file. */ + data class Line( + val index: Int, + val startMs: Long, + val endMs: Long, + val text: String, + val before: String?, + val after: String?, + /** The shift the user set: a time of the subtitle file is `offsetMs` later than the player. */ + val offsetMs: Long, + ) + + /** What the overlay knows now. [report] is on the clock of `SystemClock.elapsedRealtimeNanos()`. */ + data class Now(val report: PlayerReport?, val player: String?, val line: Line?) + + fun installed(context: Context): Boolean = + context.packageManager.resolveContentProvider(AUTHORITY, 0) != null + + /** The line of now and the player. Null when the overlay is not installed or does not answer. */ + fun now(context: Context): Now? { + if (!installed(context)) return null + val cursor = runCatching { + context.contentResolver.query("content://$AUTHORITY/line".toUri(), null, null, null, null) + }.getOrNull() ?: return null + cursor.use { c -> + if (!c.moveToFirst()) return null + val state = c.text("state") + val player = PlayerReport.overlayPackage(state) + val report = report(c, state) + val text = c.text("text") + val line = if (text == null) null else Line( + index = c.long("index")?.toInt() ?: -1, + startMs = c.long("start") ?: 0, + endMs = c.long("end") ?: 0, + text = text, + before = c.text("before"), + after = c.text("after"), + offsetMs = c.long("offset") ?: 0, + ) + return Now(report, player, line) + } + } + + /** + * The report of the player. The line row gives the raw report: the position at the moment + * the player reported it. A pause reported a minute ago still maps its media times to the + * right wall-clock times. The state row alone gives the position of now. + */ + private fun report(c: Cursor, state: String?): PlayerReport? { + val position = c.long("reported_position") + val at = c.long("reported_at") + if (position != null && position >= 0 && at != null) { + val speed = c.text("speed")?.toDoubleOrNull()?.takeIf { it > 0 } ?: 1.0 + val playing = c.long("playing") == 1L + return PlayerReport(position, at * 1_000_000L, speed, playing) + } + return PlayerReport.parseOverlayState(state, SystemClock.elapsedRealtimeNanos())?.takeIf { it.positionMs >= 0 } + } + + private fun Cursor.text(column: String): String? { + val i = getColumnIndex(column) + return if (i < 0 || isNull(i)) null else getString(i) + } + + private fun Cursor.long(column: String): Long? { + val i = getColumnIndex(column) + return if (i < 0 || isNull(i)) null else getLong(i) + } +} diff --git a/app/src/main/kotlin/space/subread/anki/Requests.kt b/app/src/main/kotlin/space/subread/anki/Requests.kt new file mode 100644 index 0000000..492fba0 --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/Requests.kt @@ -0,0 +1,66 @@ +package space.subread.anki + +import android.content.Intent +import android.net.Uri +import androidx.core.content.IntentCompat +import space.subread.anki.core.MineRequest + +/** Reads a [MineRequest] from each kind of Intent that [AddActivity] takes. */ +object Requests { + + /** The action for another app: the extras below, any subset of them. */ + const val ACTION_ADD = "space.subread.anki.action.ADD" + + /** Each key of [MineRequest.KEYS], in upper case, after this prefix: `space.subread.anki.extra.SENTENCE`. */ + const val EXTRA_PREFIX = "space.subread.anki.extra." + + /** In the result of the activity: the id of the note in AnkiDroid (`Long`). */ + const val EXTRA_NOTE_ID = "space.subread.anki.extra.NOTE_ID" + + /** In the result of the activity: why no card was added (`String`). */ + const val EXTRA_ERROR = "space.subread.anki.extra.ERROR" + + /** The name of the extra for a key of [MineRequest]. */ + fun extraName(key: String): String = EXTRA_PREFIX + key.uppercase() + + /** + * The request in an Intent, or null when the Intent is none of the kinds the app takes. + * A text from the selection menu or the share sheet is the word. A picture from the + * share sheet is the picture, with the text beside it as the word. + */ + fun fromIntent(intent: Intent): MineRequest? = when (intent.action) { + Intent.ACTION_PROCESS_TEXT -> MineRequest(expression = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()) + Intent.ACTION_SEND -> { + val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + MineRequest(expression = text, image = stream?.toString()) + } + Intent.ACTION_VIEW -> { + // A web page: the query of the link. An intent: link can carry extras too; they win. + val fromQuery = MineRequest.parseQuery(intent.data?.encodedQuery) + MineRequest.fromMap(fromQuery + fromExtras(intent)) + } + ACTION_ADD -> MineRequest.fromMap(fromExtras(intent)) + else -> null + } + + /** The extras of the request, as strings. A Uri, a number and a boolean are written as text. */ + fun fromExtras(intent: Intent): Map<String, String> { + val extras = intent.extras ?: return emptyMap() + val out = LinkedHashMap<String, String>() + for (key in MineRequest.KEYS) { + @Suppress("DEPRECATION") // The value can be of any type: only the untyped get tells which. + val value = extras.get(extraName(key)) ?: continue + out[key] = when (value) { + is Boolean -> if (value) "1" else "0" + else -> value.toString() + } + } + return out + } + + /** The Intent for another app on the device: the request as extras of [ACTION_ADD]. */ + fun toIntent(request: MineRequest): Intent = Intent(ACTION_ADD).apply { + for ((key, value) in request.toMap()) putExtra(extraName(key), value) + } +} diff --git a/app/src/main/kotlin/space/subread/anki/Store.kt b/app/src/main/kotlin/space/subread/anki/Store.kt new file mode 100644 index 0000000..4cff2aa --- /dev/null +++ b/app/src/main/kotlin/space/subread/anki/Store.kt @@ -0,0 +1,68 @@ +package space.subread.anki + +import android.content.Context +import androidx.core.content.edit +import space.subread.anki.core.Source + +/** What the user chose. It stays when the app stops. */ +class Store(context: Context) { + + private val prefs = context.getSharedPreferences("anki", Context.MODE_PRIVATE) + + /** The deck in AnkiDroid. [NONE] until the user chose one: the app then makes "SubRead". */ + var deckId: Long + get() = prefs.getLong("deck_id", NONE) + set(value) = prefs.edit { putLong("deck_id", value) } + + /** The note type in AnkiDroid. [NONE]: the note type that the app makes. */ + var modelId: Long + get() = prefs.getLong("model_id", NONE) + set(value) = prefs.edit { putLong("model_id", value) } + + /** What fills each field of the chosen note type. Empty: the app guesses from the field names. */ + var mapping: Map<String, Source> + get() = prefs.getString("mapping", "").orEmpty().lineSequence().mapNotNull { line -> + val tab = line.indexOf('\t') + if (tab < 0) null else line.substring(0, tab) to (runCatching { Source.valueOf(line.substring(tab + 1)) }.getOrNull() ?: Source.NONE) + }.toMap() + set(value) = prefs.edit { putString("mapping", value.entries.joinToString("\n") { (field, source) -> "$field\t${source.name}" }) } + + /** Anki tags for each card, separated by spaces. */ + var tags: String + get() = prefs.getString("tags", "subread") ?: "subread" + set(value) = prefs.edit { putString("tags", value.trim()) } + + /** True: the app shows the card before it adds it. False: one tap adds. */ + var confirm: Boolean + get() = prefs.getBoolean("confirm", false) + set(value) = prefs.edit { putBoolean("confirm", value) } + + /** True: a word that is in the note type already is not added again. */ + var skipDuplicates: Boolean + get() = prefs.getBoolean("skip_duplicates", true) + set(value) = prefs.edit { putBoolean("skip_duplicates", value) } + + /** True: after the card, the pop-up of SubRead Dictionary opens for the word. */ + var openDictionary: Boolean + get() = prefs.getBoolean("open_dictionary", false) + set(value) = prefs.edit { putBoolean("open_dictionary", value) } + + /** Sound before and after the subtitle line in the clip, in milliseconds. */ + var padMs: Int + get() = prefs.getInt("pad_ms", 250) + set(value) = prefs.edit { putInt("pad_ms", value.coerceIn(0, 3000)) } + + /** The last card that the app added: a picture shared later goes on it. */ + var lastNoteId: Long + get() = prefs.getLong("last_note_id", NONE) + set(value) = prefs.edit { putLong("last_note_id", value) } + + /** When the last card was added, `System.currentTimeMillis()`. */ + var lastNoteAt: Long + get() = prefs.getLong("last_note_at", 0) + set(value) = prefs.edit { putLong("last_note_at", value) } + + companion object { + const val NONE = -1L + } +} diff --git a/app/src/main/res/drawable/icon.xml b/app/src/main/res/drawable/icon.xml new file mode 100644 index 0000000..75ad4fb --- /dev/null +++ b/app/src/main/res/drawable/icon.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- A card with a corner turned: black on white, the same as the other SubRead icons. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="#FFFFFF" + android:pathData="M0,0h48v48h-48z" /> + <path + android:fillColor="#000000" + android:pathData="M10,8h20l8,8v24h-28zM12,10v28h24v-20h-8v-8zM16,22h16v2h-16zM16,27h16v2h-16zM16,32h10v2h-10z" /> +</vector> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c1e56ef --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">SubRead Anki</string> + <string name="add_label">Anki</string> + <string name="tile_label">Anki capture</string> + + <string name="about">One tap makes an Anki card: the word, its reading and definition, the sentence it is in, the sound of that sentence, and a picture of the screen. From SubRead Overlay, from the text selection menu of any app, from the share sheet, or from a web page.</string> + <string name="privacy">Nothing leaves the device. The app talks to AnkiDroid, SubRead Overlay and SubRead Dictionary on the device. It opens a URL only when a sender gives one for an audio or a picture.</string> + + <string name="step_anki">1. AnkiDroid</string> + <string name="anki_missing">AnkiDroid is not installed. The app adds the cards to it.</string> + <string name="anki_install">Install AnkiDroid</string> + <string name="anki_no_permission">Allow the app to add cards to AnkiDroid. If AnkiDroid refuses, turn on "Enable AnkiDroid API" in its settings, under Advanced.</string> + <string name="anki_allow">Allow</string> + <string name="anki_ok">AnkiDroid is ready.</string> + + <string name="step_deck">2. Deck and note type</string> + <string name="deck_current">Deck: %1$s</string> + <string name="deck_choose">Choose the deck</string> + <string name="deck_new">New deck…</string> + <string name="deck_new_name">The name of the new deck</string> + <string name="model_current">Note type: %1$s</string> + <string name="model_own">Note type: %1$s (the app makes it in AnkiDroid)</string> + <string name="model_choose">Choose the note type</string> + <string name="model_own_choice">SubRead Anki (made by the app)</string> + <string name="fields">Fields</string> + <string name="fields_why">What goes into each field of the note type. The app guesses from the names; Lapis and the basic note types work as they are.</string> + <string name="field_source">%1$s ← %2$s</string> + + <string name="step_subread">3. SubRead Overlay and Dictionary</string> + <string name="overlay_ok">SubRead Overlay is installed. In its settings, choose "Anki" as the dictionary: a tap on a word of the subtitle line makes the card, with the line as the sentence and its sound.</string> + <string name="overlay_missing">SubRead Overlay is not installed. With it, a tap on a word of the subtitle line makes the card, with the line as the sentence and its sound.</string> + <string name="dictionary_ok">SubRead Dictionary is installed: it gives the reading, the definition, the pitch, the frequency and the word audio.</string> + <string name="dictionary_old">SubRead Dictionary is installed, but this version does not answer other apps. Update it for the definitions.</string> + <string name="dictionary_missing">SubRead Dictionary is not installed. Without it, a card has no definition unless the sender gives one.</string> + <string name="open_overlay">Open SubRead Overlay</string> + <string name="open_dictionary">Open SubRead Dictionary</string> + <string name="install_overlay">Install SubRead Overlay</string> + <string name="install_dictionary">Install SubRead Dictionary</string> + + <string name="step_capture">4. Capture</string> + <string name="capture_why">The sound of the player and a picture of the screen go on the card while the capture is on. Android asks each time it starts. The quick settings have a tile for it. A player that hides its sound (DRM) gives no sound.</string> + <string name="capture_no_audio">This Android version captures the screen only; the sound of other apps needs Android 10.</string> + <string name="capture_start">Start the capture</string> + <string name="capture_stop">Stop the capture</string> + <string name="capture_notification">Capturing the sound and the screen for the cards</string> + <string name="capture_channel">Capture</string> + <string name="capture_denied">Android did not allow the capture.</string> + <string name="capture_no_record">The capture needs the microphone permission of Android, for the sound of the player. No microphone is recorded.</string> + <string name="pad">Sound before and after the line, in milliseconds</string> + + <string name="step_options">5. Options</string> + <string name="confirm_on">Ask before adding: on</string> + <string name="confirm_off">Ask before adding: off (one tap adds)</string> + <string name="duplicates_skip">A word that is in the deck already: skip</string> + <string name="duplicates_add">A word that is in the deck already: add again</string> + <string name="open_dictionary_on">After the card, open SubRead Dictionary: on</string> + <string name="open_dictionary_off">After the card, open SubRead Dictionary: off</string> + <string name="tags">Tags, separated by spaces</string> + <string name="save">Save</string> + <string name="saved">Saved.</string> + <string name="toggle">Change</string> + + <string name="step_try">6. Try it</string> + <string name="try_hint">A word, or a sentence</string> + <string name="try_add">Add a card</string> + + <string name="donate">Support the app on Ko-fi</string> + + <string name="no_text">The request is empty: no word and no sentence.</string> + <string name="added">Added to Anki: %1$s</string> + <string name="added_sentence">Added the sentence to Anki</string> + <string name="duplicate">Already in Anki: %1$s</string> + <string name="failed">Anki did not take the card: %1$s</string> + <string name="confirm_title">Add to Anki</string> + <string name="confirm_add">Add</string> + <string name="confirm_cancel">Cancel</string> + <string name="confirm_sentence_only">Sentence card, no word</string> + <string name="confirm_media">Sound: %1$s Picture: %2$s Word audio: %3$s</string> + <string name="yes">yes</string> + <string name="no">no</string> + <string name="picture_attached">Picture added to the last card</string> + <string name="picture_no_card">No card of the last hour to add the picture to</string> +</resources> diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..af6f445 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths> + <!-- The media files wait here until AnkiDroid has copied them. --> + <cache-path name="media" path="media/" /> +</paths> diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6ea42bf --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Plugin versions live in gradle/libs.versions.toml; each module applies its own. +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.android.application) apply false +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..fa91e99 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,29 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// Plain JVM on purpose: no Android types in here, so the logic can be tested on a laptop. +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + +dependencies { + testImplementation(libs.junit) + // Property tests: they shrink a failing input to the smallest one and print the seed. + testImplementation(libs.kotest.property) + testImplementation(libs.kotlinx.coroutines.core) +} + +tasks.test { + useJUnit() + testLogging { + events("failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/core/src/main/kotlin/space/subread/anki/core/Fields.kt b/core/src/main/kotlin/space/subread/anki/core/Fields.kt new file mode 100644 index 0000000..a18cd22 --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/Fields.kt @@ -0,0 +1,132 @@ +package space.subread.anki.core + +/** What the app knows about a card, with the media already in the Anki media folder. */ +data class Note( + val expression: String = "", + val reading: String = "", + /** HTML. */ + val definition: String = "", + /** Plain text. */ + val sentence: String = "", + /** The name of the sentence audio in the media folder of Anki, or empty. */ + val sentenceAudioFile: String = "", + /** The name of the word audio in the media folder of Anki, or empty. */ + val wordAudioFile: String = "", + /** The name of the picture in the media folder of Anki, or empty. */ + val imageFile: String = "", + val source: String = "", + val pitch: String = "", + val frequency: String = "", + /** The word as it is in the sentence, when it differs from [expression]: `食べた` for `食べる`. */ + val selection: String = "", +) + +/** What a field of the note type is filled with. */ +enum class Source { + /** Nothing: the field stays empty. */ + NONE, + EXPRESSION, + READING, + /** `漢字[かんじ]`: the expression with its reading, as Anki writes furigana. */ + FURIGANA, + DEFINITION, + /** The sentence, escaped. */ + SENTENCE, + /** The sentence with the word in `<b>`, as the word is in the sentence. */ + SENTENCE_BOLD, + /** `[sound:...]` of the sentence. */ + SENTENCE_AUDIO, + /** `[sound:...]` of the word. */ + WORD_AUDIO, + /** `<img src="...">`. */ + IMAGE, + SOURCE, + PITCH, + FREQUENCY, + /** An `x`: the note type shows a card layout when the field is not empty (Lapis). */ + MARK, +} + +/** Fills the fields of a note type from a [Note]. */ +object Fields { + /** The value of one [Source] for a note. */ + fun value(note: Note, source: Source): String = when (source) { + Source.NONE -> "" + Source.EXPRESSION -> note.expression + Source.READING -> note.reading + Source.FURIGANA -> furigana(note.expression, note.reading) + Source.DEFINITION -> note.definition + Source.SENTENCE -> Sentences.escape(note.sentence) + Source.SENTENCE_BOLD -> Sentences.emphasize(note.sentence, note.selection.ifEmpty { note.expression }.takeIf { it.isNotEmpty() }) + Source.SENTENCE_AUDIO -> sound(note.sentenceAudioFile) + Source.WORD_AUDIO -> sound(note.wordAudioFile) + Source.IMAGE -> image(note.imageFile) + Source.SOURCE -> Sentences.escape(note.source) + Source.PITCH -> note.pitch + Source.FREQUENCY -> note.frequency + Source.MARK -> "x" + } + + /** One value for each of [fieldNames], from [mapping]. A field with no mapping is empty. */ + fun render(note: Note, fieldNames: List<String>, mapping: Map<String, Source>): Array<String> = + Array(fieldNames.size) { i -> value(note, mapping[fieldNames[i]] ?: Source.NONE) } + + /** `[sound:name]`, or empty for no file. */ + fun sound(file: String): String = if (file.isEmpty()) "" else "[sound:$file]" + + /** `<img src="name">`, or empty for no file. */ + fun image(file: String): String = if (file.isEmpty()) "" else "<img src=\"${Sentences.escape(file)}\">" + + /** + * The expression with its reading in the furigana form of Anki: ` 漢字[かんじ]`. The + * expression alone when the reading is empty or the same as the expression. + */ + fun furigana(expression: String, reading: String): String = + if (reading.isEmpty() || reading == expression || expression.isEmpty()) expression + else " $expression[$reading]" + + /** + * A mapping for a note type from the names of its fields. Names are compared without + * case and without anything but letters and digits, so `Sentence Audio`, + * `sentence_audio` and `SentenceAudio` are the same field. Unknown fields get [Source.NONE]. + */ + fun guess(fieldNames: List<String>): Map<String, Source> { + val out = LinkedHashMap<String, Source>() + val taken = HashSet<Source>() + for (name in fieldNames) { + val source = BY_NAME[normalize(name)] ?: Source.NONE + // The first field of a kind wins: two "Definition" fields do not both get it. + out[name] = if (source != Source.NONE && source != Source.MARK && !taken.add(source)) Source.NONE else source + } + // A note type with no named expression field is a basic one: its first field gets + // the expression, its second the definition. + if (Source.EXPRESSION !in out.values && fieldNames.isNotEmpty() && out[fieldNames[0]] == Source.NONE) { + out[fieldNames[0]] = Source.EXPRESSION + if (Source.DEFINITION !in out.values && fieldNames.size > 1 && out[fieldNames[1]] == Source.NONE) { + out[fieldNames[1]] = Source.DEFINITION + } + } + return out + } + + /** The name of a field with case, spaces and punctuation removed. */ + fun normalize(name: String): String = name.lowercase().filter { it.isLetterOrDigit() } + + private val BY_NAME: Map<String, Source> = buildMap { + fun put(source: Source, vararg names: String) = names.forEach { put(normalize(it), source) } + put(Source.EXPRESSION, "Expression", "Word", "Term", "Vocab", "Vocabulary", "Target", "Kanji", "単語", "表現", "Front") + put(Source.READING, "ExpressionReading", "Reading", "Yomi", "Kana", "Pronunciation", "読み", "Word Reading") + put(Source.FURIGANA, "ExpressionFurigana", "Furigana", "VocabFurigana", "Word Furigana") + put(Source.WORD_AUDIO, "ExpressionAudio", "WordAudio", "Audio", "VocabAudio", "Word Audio") + put( + Source.DEFINITION, + "MainDefinition", "Definition", "Definitions", "Meaning", "Meanings", "Glossary", "Gloss", "Back", "意味", + ) + put(Source.SENTENCE_BOLD, "Sentence", "Context", "Example", "ExampleSentence", "SentenceCloze", "Sentence Cloze", "文") + put(Source.SENTENCE_AUDIO, "SentenceAudio", "ContextAudio", "AudioSentence", "ExampleAudio", "Sentence Audio") + put(Source.IMAGE, "Picture", "Image", "Screenshot", "Img", "Photo") + put(Source.SOURCE, "Source", "MiscInfo", "Document", "DocumentTitle", "Title", "Origin", "Notes", "Note") + put(Source.PITCH, "PitchPosition", "Pitch", "PitchAccent", "Accent", "PitchCategories") + put(Source.FREQUENCY, "Frequency", "FreqSort", "Freq", "Frequencies") + } +} diff --git a/core/src/main/kotlin/space/subread/anki/core/MediaNames.kt b/core/src/main/kotlin/space/subread/anki/core/MediaNames.kt new file mode 100644 index 0000000..d1eac51 --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/MediaNames.kt @@ -0,0 +1,26 @@ +package space.subread.anki.core + +/** Names for the files that go into the media folder of Anki. */ +object MediaNames { + /** The longest part of a name taken from the expression. */ + const val MAX_STEM = 40 + + /** + * `subread_<word>_<stamp>`: the letters and digits of [expression] in any script; the + * rest becomes `_`. An empty word becomes `card`. The stamp makes the name unique. The + * files of one card share the stem and differ in the extension or a suffix. + */ + fun stem(expression: String, stampMillis: Long): String { + var word = buildString { + for (c in expression) append(if (c.isLetterOrDigit()) c else '_') + }.trim('_').replace(Regex("_+"), "_").take(MAX_STEM).trim('_') + if (word.isEmpty()) word = "card" + return "subread_${word}_$stampMillis" + } + + /** [stem] with an extension. */ + fun name(expression: String, stampMillis: Long, extension: String): String { + require(extension.isNotEmpty() && extension.all { it.isLetterOrDigit() }) { "bad extension $extension" } + return "${stem(expression, stampMillis)}.$extension" + } +} diff --git a/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt b/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt new file mode 100644 index 0000000..b308c2d --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/MineRequest.kt @@ -0,0 +1,175 @@ +package space.subread.anki.core + +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * One request to add a card. Every part is optional: the app fills what is missing from + * the overlay, the dictionary and the capture. A request with no expression and no + * sentence is empty and is refused. + * + * The same keys are the query of `subreadanki://add?...` and, with the prefix + * `space.subread.anki.extra.` and in upper case, the extras of the Intent. + */ +data class MineRequest( + /** The word, as it is in the text. */ + val expression: String? = null, + /** The reading of the word (kana, pinyin, ...). */ + val reading: String? = null, + /** The definition, HTML or plain text. */ + val definition: String? = null, + /** The sentence that the word is in, plain text. */ + val sentence: String? = null, + /** A Uri of the audio of the sentence: `content://`, `https://`, `http://` or `data:`. */ + val audio: String? = null, + /** With [audio]: the start of the clip in the audio, in milliseconds. */ + val audioStartMs: Long? = null, + /** With [audio]: the end of the clip in the audio, in milliseconds. */ + val audioEndMs: Long? = null, + /** A Uri of the audio of the word alone. */ + val wordAudio: String? = null, + /** A Uri of the picture. */ + val image: String? = null, + /** Where the sentence is from: the title of the book or the video. */ + val source: String? = null, + /** Anki tags. A tag has no space in it. */ + val tags: List<String> = emptyList(), + /** Pitch accent, as text. */ + val pitch: String? = null, + /** Frequency, as text. */ + val frequency: String? = null, + /** The position of the player when the user tapped, in milliseconds. */ + val positionMs: Long? = null, + /** The start of the subtitle line in the media, in milliseconds. */ + val cueStartMs: Long? = null, + /** The end of the subtitle line in the media, in milliseconds. */ + val cueEndMs: Long? = null, + /** True: show the card before it is added, whatever the setting says. False: add at once. */ + val confirm: Boolean? = null, +) { + /** True when there is nothing to make a card from. */ + val isEmpty: Boolean get() = expression.isNullOrBlank() && sentence.isNullOrBlank() + + /** The request as the query of `subreadanki://add`. Keys with no value are left out. */ + fun toQuery(): String = toMap().entries.joinToString("&") { (key, value) -> "$key=${encode(value)}" } + + /** The request as `subreadanki://add?...`. */ + fun toUri(): String = "$SCHEME://$HOST?${toQuery()}" + + /** The request as key and value strings. Keys with no value are left out. */ + fun toMap(): Map<String, String> { + val out = LinkedHashMap<String, String>() + fun put(key: String, value: String?) { + if (value != null) out[key] = value + } + put(KEY_EXPRESSION, expression) + put(KEY_READING, reading) + put(KEY_DEFINITION, definition) + put(KEY_SENTENCE, sentence) + put(KEY_AUDIO, audio) + put(KEY_AUDIO_START, audioStartMs?.toString()) + put(KEY_AUDIO_END, audioEndMs?.toString()) + put(KEY_WORD_AUDIO, wordAudio) + put(KEY_IMAGE, image) + put(KEY_SOURCE, source) + if (tags.isNotEmpty()) out[KEY_TAGS] = tags.joinToString(" ") + put(KEY_PITCH, pitch) + put(KEY_FREQUENCY, frequency) + put(KEY_POSITION, positionMs?.toString()) + put(KEY_CUE_START, cueStartMs?.toString()) + put(KEY_CUE_END, cueEndMs?.toString()) + put(KEY_CONFIRM, confirm?.let { if (it) "1" else "0" }) + return out + } + + companion object { + const val SCHEME = "subreadanki" + const val HOST = "add" + + const val KEY_EXPRESSION = "expression" + const val KEY_READING = "reading" + const val KEY_DEFINITION = "definition" + const val KEY_SENTENCE = "sentence" + const val KEY_AUDIO = "audio" + const val KEY_AUDIO_START = "audio_start" + const val KEY_AUDIO_END = "audio_end" + const val KEY_WORD_AUDIO = "word_audio" + const val KEY_IMAGE = "image" + const val KEY_SOURCE = "source" + const val KEY_TAGS = "tags" + const val KEY_PITCH = "pitch" + const val KEY_FREQUENCY = "frequency" + const val KEY_POSITION = "position" + const val KEY_CUE_START = "cue_start" + const val KEY_CUE_END = "cue_end" + const val KEY_CONFIRM = "confirm" + + /** Every key, in the order of the documentation. */ + val KEYS: List<String> = listOf( + KEY_EXPRESSION, KEY_READING, KEY_DEFINITION, KEY_SENTENCE, KEY_AUDIO, KEY_AUDIO_START, + KEY_AUDIO_END, KEY_WORD_AUDIO, KEY_IMAGE, KEY_SOURCE, KEY_TAGS, KEY_PITCH, KEY_FREQUENCY, + KEY_POSITION, KEY_CUE_START, KEY_CUE_END, KEY_CONFIRM, + ) + + /** + * Reads a request from key and value strings. Unknown keys are ignored. An empty + * value, or a number that does not parse, counts as absent. + */ + fun fromMap(map: Map<String, String>): MineRequest { + fun text(key: String): String? = map[key]?.takeIf { it.isNotEmpty() } + fun number(key: String): Long? = text(key)?.trim()?.toLongOrNull() + return MineRequest( + expression = text(KEY_EXPRESSION), + reading = text(KEY_READING), + definition = text(KEY_DEFINITION), + sentence = text(KEY_SENTENCE), + audio = text(KEY_AUDIO), + audioStartMs = number(KEY_AUDIO_START), + audioEndMs = number(KEY_AUDIO_END), + wordAudio = text(KEY_WORD_AUDIO), + image = text(KEY_IMAGE), + source = text(KEY_SOURCE), + tags = text(KEY_TAGS)?.split(Regex("\\s+"))?.filter { it.isNotEmpty() } ?: emptyList(), + pitch = text(KEY_PITCH), + frequency = text(KEY_FREQUENCY), + positionMs = number(KEY_POSITION), + cueStartMs = number(KEY_CUE_START), + cueEndMs = number(KEY_CUE_END), + confirm = when (text(KEY_CONFIRM)?.trim()?.lowercase()) { + null -> null + "1", "true", "yes" -> true + else -> false + }, + ) + } + + /** + * Reads a request from the query of `subreadanki://add?...`, percent-encoded. A pair + * without `=` is a key with an empty value. `+` is a space, as `URLEncoder` writes it. + */ + fun fromQuery(query: String?): MineRequest = fromMap(parseQuery(query)) + + /** The pairs of a percent-encoded query, decoded. The last value of a repeated key wins. */ + fun parseQuery(query: String?): Map<String, String> { + if (query.isNullOrEmpty()) return emptyMap() + val out = LinkedHashMap<String, String>() + for (pair in query.split('&')) { + if (pair.isEmpty()) continue + val eq = pair.indexOf('=') + val key = if (eq < 0) pair else pair.substring(0, eq) + val value = if (eq < 0) "" else pair.substring(eq + 1) + out[decode(key)] = decode(value) + } + return out + } + + private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8) + + private fun decode(value: String): String = try { + URLDecoder.decode(value, Charsets.UTF_8) + } catch (e: IllegalArgumentException) { + // A stray `%`: keep the text as it is instead of losing the request. + value + } + } +} diff --git a/core/src/main/kotlin/space/subread/anki/core/NoteType.kt b/core/src/main/kotlin/space/subread/anki/core/NoteType.kt new file mode 100644 index 0000000..382996e --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/NoteType.kt @@ -0,0 +1,67 @@ +package space.subread.anki.core + +/** The note type that the app makes in AnkiDroid when the user has none to choose. */ +object NoteType { + const val NAME = "SubRead Anki" + + const val FIELD_EXPRESSION = "Expression" + const val FIELD_READING = "Reading" + const val FIELD_DEFINITION = "Definition" + const val FIELD_SENTENCE = "Sentence" + const val FIELD_SENTENCE_AUDIO = "SentenceAudio" + const val FIELD_WORD_AUDIO = "WordAudio" + const val FIELD_IMAGE = "Image" + const val FIELD_SOURCE = "Source" + + /** The fields, in order. The first is the sort field. */ + val FIELDS: List<String> = listOf( + FIELD_EXPRESSION, FIELD_READING, FIELD_DEFINITION, FIELD_SENTENCE, + FIELD_SENTENCE_AUDIO, FIELD_WORD_AUDIO, FIELD_IMAGE, FIELD_SOURCE, + ) + + /** The fields, with what fills each. */ + val MAPPING: Map<String, Source> = mapOf( + FIELD_EXPRESSION to Source.EXPRESSION, + FIELD_READING to Source.READING, + FIELD_DEFINITION to Source.DEFINITION, + FIELD_SENTENCE to Source.SENTENCE_BOLD, + FIELD_SENTENCE_AUDIO to Source.SENTENCE_AUDIO, + FIELD_WORD_AUDIO to Source.WORD_AUDIO, + FIELD_IMAGE to Source.IMAGE, + FIELD_SOURCE to Source.SOURCE, + ) + + /** One card: the word and its sentence in front; the rest behind. */ + val CARD_NAMES: List<String> = listOf("Card 1") + + val QUESTION_FORMATS: List<String> = listOf( + """<div class="expression">{{Expression}}</div> +<div class="sentence">{{Sentence}}</div>""", + ) + + val ANSWER_FORMATS: List<String> = listOf( + """{{FrontSide}} +<hr id="answer"> +<div class="reading">{{Reading}}</div> +<div class="audio">{{WordAudio}} {{SentenceAudio}}</div> +<div class="definition">{{Definition}}</div> +<div class="image">{{Image}}</div> +<div class="source">{{Source}}</div>""", + ) + + /** Black on white, large, still: readable on an e-ink screen. */ + const val CSS = """.card { + font-family: sans-serif; + font-size: 22px; + text-align: center; + color: black; + background-color: white; +} +.expression { font-size: 44px; } +.sentence { margin-top: 12px; } +.reading { font-size: 26px; } +.definition { text-align: left; margin: 12px 8px; } +.image img { max-width: 100%; max-height: 60vh; } +.source { font-size: 16px; color: #555; } +""" +} diff --git a/core/src/main/kotlin/space/subread/anki/core/PcmRing.kt b/core/src/main/kotlin/space/subread/anki/core/PcmRing.kt new file mode 100644 index 0000000..831fdaf --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/PcmRing.kt @@ -0,0 +1,92 @@ +package space.subread.anki.core + +/** + * The last seconds of captured sound, 16-bit PCM, with a wall clock. The capture writes + * what it records; a request reads the frames of a wall-clock range back out. + * + * The clock: each write says when its last frame was recorded. Frames before it are + * spaced by the sample rate. The capture is continuous, so the end of one write is the + * start of the next. + * + * Not thread-safe: the caller locks. + */ +class PcmRing(val sampleRate: Int, val channels: Int, capacityFrames: Int) { + init { + require(sampleRate > 0) { "sampleRate must be above zero" } + require(channels > 0) { "channels must be above zero" } + require(capacityFrames > 0) { "capacityFrames must be above zero" } + } + + /** How many frames the ring holds. */ + val capacityFrames: Int = capacityFrames + + private val data = ShortArray(capacityFrames * channels) + + /** Frames written since the start, in total. */ + var framesWritten: Long = 0 + private set + + /** The wall clock (nanoseconds) of the end of the last frame written. */ + var endNanos: Long = 0 + private set + + /** Frames in the ring now. */ + val framesHeld: Long get() = minOf(framesWritten, capacityFrames.toLong()) + + /** The wall clock of the start of the oldest frame held. */ + val startNanos: Long get() = endNanos - framesToNanos(framesHeld) + + /** True when nothing was written yet. */ + val isEmpty: Boolean get() = framesWritten == 0L + + /** + * Appends [frames] frames from [samples] (interleaved, `frames * channels` values). + * [endNanos] is the wall clock at the end of the last of them. + */ + fun write(samples: ShortArray, frames: Int, endNanos: Long) { + require(frames >= 0 && frames * channels <= samples.size) { "frames $frames do not fit the samples" } + var offset = 0 + var count = frames + if (count > capacityFrames) { + // More than the ring holds: the head is as good as written and gone. + val skip = count - capacityFrames + offset = skip * channels + framesWritten += skip + count = capacityFrames + } + val values = count * channels + val at = ((framesWritten % capacityFrames) * channels).toInt() + val first = minOf(values, data.size - at) + System.arraycopy(samples, offset, data, at, first) + if (values > first) System.arraycopy(samples, offset + first, data, 0, values - first) + framesWritten += count + this.endNanos = endNanos + } + + /** + * The frames recorded from [fromNanos] to [toNanos], interleaved. The range is cut to + * what the ring holds. An empty array when nothing of the range is held. + */ + fun read(fromNanos: Long, toNanos: Long): ShortArray { + if (isEmpty || toNanos <= fromNanos) return ShortArray(0) + val from = maxOf(fromNanos, startNanos) + val to = minOf(toNanos, endNanos) + if (to <= from) return ShortArray(0) + val firstFrame = framesWritten - nanosToFrames(endNanos - from) + val lastFrame = framesWritten - nanosToFrames(endNanos - to) + val count = (lastFrame - firstFrame).toInt() + if (count <= 0) return ShortArray(0) + val out = ShortArray(count * channels) + val at = ((firstFrame % capacityFrames) * channels).toInt() + val first = minOf(out.size, data.size - at) + System.arraycopy(data, at, out, 0, first) + if (out.size > first) System.arraycopy(data, 0, out, first, out.size - first) + return out + } + + /** Nanoseconds for a count of frames. */ + fun framesToNanos(frames: Long): Long = frames * 1_000_000_000L / sampleRate + + /** Frames for a count of nanoseconds, rounded down. */ + fun nanosToFrames(nanos: Long): Long = nanos * sampleRate / 1_000_000_000L +} diff --git a/core/src/main/kotlin/space/subread/anki/core/PlayerReport.kt b/core/src/main/kotlin/space/subread/anki/core/PlayerReport.kt new file mode 100644 index 0000000..a746f51 --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/PlayerReport.kt @@ -0,0 +1,66 @@ +package space.subread.anki.core + +/** + * What a player said about its position: the position, the wall-clock moment of that + * report, and the speed. The same three numbers as the media session of Android. + * + * The report maps media time to wall-clock time in both directions, also when the player + * is paused: the pause is the anchor, and the media before it ran at [speed]. + */ +data class PlayerReport( + /** The position in the media at [atNanos], in milliseconds. */ + val positionMs: Long, + /** The wall clock of the report, `System.nanoTime()`. */ + val atNanos: Long, + /** The playback speed. 1.0 is normal. Must be above zero. */ + val speed: Double = 1.0, + /** True while the player plays. */ + val playing: Boolean = true, +) { + init { + require(speed > 0.0) { "speed must be above zero, was $speed" } + } + + /** The wall-clock moment when the media was, or will be, at [mediaMs]. */ + fun wallAt(mediaMs: Long): Long = + atNanos + Math.round((mediaMs - positionMs) * NANOS_PER_MILLI / speed) + + /** The position of the media at the wall-clock moment [nanos]. */ + fun mediaAt(nanos: Long): Long = + positionMs + Math.round((nanos - atNanos) * speed / NANOS_PER_MILLI) + + /** + * The wall-clock range in which the media from [startMs] to [endMs] played, with + * [padMs] of media added on each side. + */ + fun wallRange(startMs: Long, endMs: Long, padMs: Long = 0): LongRange { + require(endMs >= startMs) { "end $endMs is before start $startMs" } + return wallAt(startMs - padMs)..wallAt(endMs + padMs) + } + + companion object { + const val NANOS_PER_MILLI = 1_000_000.0 + + /** + * Reads the `state` row of the SubRead Overlay player provider: + * `playing=1;position=96153;speed=1.0;package=de.ph1b.audiobook`. Null for an error + * row (`error=no_player`) or a row without a position. + */ + fun parseOverlayState(state: String?, atNanos: Long): PlayerReport? { + if (state.isNullOrBlank()) return null + val pairs = state.split(';').mapNotNull { pair -> + val eq = pair.indexOf('=') + if (eq < 0) null else pair.substring(0, eq).trim() to pair.substring(eq + 1).trim() + }.toMap() + if (pairs.containsKey("error")) return null + val position = pairs["position"]?.toLongOrNull() ?: return null + val speed = pairs["speed"]?.toDoubleOrNull()?.takeIf { it > 0.0 } ?: 1.0 + val playing = pairs["playing"] == "1" || pairs["playing"] == "true" + return PlayerReport(position, atNanos, speed, playing) + } + + /** The package of the player in an overlay `state` row, or null. */ + fun overlayPackage(state: String?): String? = + state?.split(';')?.firstOrNull { it.startsWith("package=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } + } +} diff --git a/core/src/main/kotlin/space/subread/anki/core/Sentences.kt b/core/src/main/kotlin/space/subread/anki/core/Sentences.kt new file mode 100644 index 0000000..f5eb858 --- /dev/null +++ b/core/src/main/kotlin/space/subread/anki/core/Sentences.kt @@ -0,0 +1,90 @@ +package space.subread.anki.core + +/** Finds the sentence around a word, and marks the word in it. */ +object Sentences { + /** Characters that end a sentence. */ + private const val TERMINATORS = "。。.!?!?…‥\n\r" + + /** Quotes and brackets that close a sentence after its terminator: `「猫だ。」` */ + private const val CLOSERS = "」』))】〉》\"'’”" + + /** True for a character that ends a sentence. An ASCII period counts only before a space, so `3.5` stays one. */ + fun isTerminator(c: Char): Boolean = c in TERMINATORS + + /** True when the character at [index] ends a sentence, with the ASCII period rule applied. */ + fun endsAt(text: String, index: Int): Boolean { + val c = text[index] + if (c in TERMINATORS) return true + return c == '.' && (index + 1 >= text.length || text[index + 1].isWhitespace()) + } + + private fun isLineBreak(c: Char) = c == '\n' || c == '\r' + + /** + * The range of the sentence that holds the character at [index]. The sentence runs from + * the character after the previous terminator (and its closers) to the next terminator + * and its closers, both inclusive. A line break ends a sentence but is not part of it. + * Spaces at the ends are not trimmed here. + */ + fun rangeAround(text: String, index: Int): IntRange { + require(index in text.indices) { "index $index is not in the text" } + var start = index + while (start > 0 && !endsAt(text, start - 1)) start-- + // A closer right after the previous terminator belongs to the previous sentence. + while (start < index && text[start] in CLOSERS) start++ + var end = index + while (end < text.length && !endsAt(text, end)) end++ + if (end < text.length && !isLineBreak(text[end])) { + // Take the terminator, and every terminator that follows it: `…。` or `!?`, then the closers. + while (end < text.length && endsAt(text, end) && !isLineBreak(text[end])) end++ + while (end < text.length && text[end] in CLOSERS) end++ + } + return start until end + } + + /** + * The sentence of [text] that holds [word], trimmed, or null when the word is not in the + * text. The first occurrence of the word counts. + */ + fun around(text: String, word: String): String? { + if (word.isEmpty()) return null + val at = text.indexOf(word) + if (at < 0) return null + return text.substring(rangeAround(text, at)).trim() + } + + /** + * The sentence as HTML, with the first occurrence of [word] in `<b>`. When the word is + * not in the sentence, the sentence is only escaped. + */ + fun emphasize(sentence: String, word: String?): String { + if (word.isNullOrEmpty()) return escape(sentence) + val at = sentence.indexOf(word) + if (at < 0) return escape(sentence) + return escape(sentence.substring(0, at)) + "<b>" + escape(word) + "</b>" + + escape(sentence.substring(at + word.length)) + } + + /** Text made safe for HTML. */ + fun escape(text: String): String = buildString(text.length) { + for (c in text) { + when (c) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '"' -> append(""") + else -> append(c) + } + } + } + + /** The reverse of [escape]. */ + fun unescape(html: String): String = html + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("&", "&") + + /** HTML with its tags removed. Entities stay as they are. */ + fun stripTags(html: String): String = html.replace(Regex("<[^>]*>"), "") +} diff --git a/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt new file mode 100644 index 0000000..2aa469d --- /dev/null +++ b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt @@ -0,0 +1,393 @@ +package space.subread.anki.core + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.element +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.orNull +import io.kotest.property.arbitrary.stringPattern +import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The pieces of a text that a request meets: kana, kanji, an emoji, HTML, and the query separators. */ +private val pieces: List<String> = ('a'..'z').map { it.toString() } + + listOf(" ", "&", "=", "%", "+", "/", "?", "#", "<", ">", "\"", "\n", "猫", "ね", "こ", "ü", "😀", "。") + +/** A text of up to [most] pieces, from [from]. */ +private fun text(most: Int, from: List<String> = pieces, least: Int = 0): Arb<String> = + Arb.list(Arb.element(from), least..most).map { it.joinToString("") } + +private val anyText: Arb<String> = text(40) + +/** Runs a property test. JUnit wants a test method to return nothing; `runBlocking` alone would return the context. */ +private fun property(block: suspend () -> Unit) { + runBlocking { block() } +} + +private val tag: Arb<String> = Arb.stringPattern("[a-z0-9:_]{1,8}") + +private val anyRequest: Arb<MineRequest> = arbitrary { + val text = anyText.orNull(0.3) + val number = Arb.long(0L..10_000_000L).orNull(0.3) + MineRequest( + expression = text.bind(), + reading = text.bind(), + definition = text.bind(), + sentence = text.bind(), + audio = text.bind(), + audioStartMs = number.bind(), + audioEndMs = number.bind(), + wordAudio = text.bind(), + image = text.bind(), + source = text.bind(), + tags = Arb.list(tag, 0..4).bind(), + pitch = text.bind(), + frequency = text.bind(), + positionMs = number.bind(), + cueStartMs = number.bind(), + cueEndMs = number.bind(), + confirm = Arb.boolean().orNull(0.3).bind(), + ) +} + +/** A request without empty strings: an empty value is the same as no value after a round trip. */ +private fun MineRequest.normalized(): MineRequest = copy( + expression = expression?.ifEmpty { null }, + reading = reading?.ifEmpty { null }, + definition = definition?.ifEmpty { null }, + sentence = sentence?.ifEmpty { null }, + audio = audio?.ifEmpty { null }, + wordAudio = wordAudio?.ifEmpty { null }, + image = image?.ifEmpty { null }, + source = source?.ifEmpty { null }, + pitch = pitch?.ifEmpty { null }, + frequency = frequency?.ifEmpty { null }, +) + +/** The request survives the query of the URL and the extras of the Intent. */ +class MineRequestTest { + + @Test + fun theQueryRoundTrips() = property { + checkAll(anyRequest) { request -> + assertEquals(request.normalized(), MineRequest.fromQuery(request.toQuery())) + } + } + + @Test + fun theMapRoundTrips() = property { + checkAll(anyRequest) { request -> + assertEquals(request.normalized(), MineRequest.fromMap(request.toMap())) + } + } + + @Test + fun theUriHasTheSchemeAndTheHost() = property { + checkAll(anyRequest) { request -> + val uri = request.toUri() + assertTrue(uri.startsWith("subreadanki://add?")) + assertEquals(request.normalized(), MineRequest.fromQuery(uri.substringAfter('?'))) + } + } + + @Test + fun unknownKeysAndBadNumbersAreIgnored() { + val request = MineRequest.fromQuery("expression=%E7%8C%AB&foo=bar&audio_start=abc&cue_end=&confirm=yes&tags=a+b%20%20c") + assertEquals("猫", request.expression) + assertNull(request.audioStartMs) + assertNull(request.cueEndMs) + assertEquals(true, request.confirm) + assertEquals(listOf("a", "b", "c"), request.tags) + } + + @Test + fun aStrayPercentKeepsTheText() { + assertEquals("100%", MineRequest.fromQuery("expression=100%").expression) + assertEquals("", MineRequest.fromQuery("").expression ?: "") + assertTrue(MineRequest.fromQuery(null).isEmpty) + } + + @Test + fun aRequestWithNoWordAndNoSentenceIsEmpty() { + assertTrue(MineRequest(definition = "x").isEmpty) + assertFalse(MineRequest(sentence = "x").isEmpty) + assertFalse(MineRequest(expression = "x").isEmpty) + } +} + +/** Sentences around a word. */ +class SentencesTest { + + private val sentenceBody: Arb<String> = text(12, listOf("a", "b", "c", "d", "猫", "ね", "こ", " ", "「", "」"), least = 1) + private val terminator: Arb<String> = Arb.element(listOf("。", "!", "?", "!", "?", "…", "\n", "。」", "!?")) + + @Test + fun theSentenceOfAWordIsTheSentenceItWasBuiltFrom() = property { + checkAll(Arb.list(sentenceBody, 1..5), Arb.list(terminator, 5..5), Arb.int(0..4)) { bodies, ends, pick -> + val sentences = bodies.mapIndexed { i, body -> body + ends[i] } + val text = sentences.joinToString("") + val i = pick % sentences.size + val body = bodies[i] + // A body that starts with a closer belongs to the sentence before it, by design. + if (bodies.any { it.startsWith("」") }) return@checkAll + val word = body.trim().takeIf { it.isNotEmpty() && '」' !in it && '「' !in it } ?: return@checkAll + // The first occurrence of the word counts: skip a word that is in an earlier sentence. + if (sentences.take(i).any { it.contains(word) }) return@checkAll + val found = Sentences.around(text, word) + assertEquals(sentences[i].trim(), found) + } + } + + @Test + fun theSentenceIsInTheTextAndHoldsTheWord() = property { + checkAll(anyText, text(3, listOf("a", "b", "猫", "ね", "。"), least = 1)) { text, word -> + val found = Sentences.around(text, word) + if (!text.contains(word)) { + assertNull(found) + } else { + assertTrue("$found is in $text", text.contains(found!!)) + // A word with the end of a sentence in it is no word: the sentence of its first character is fine. + if (word.none { Sentences.isTerminator(it) }) assertTrue("$found holds $word", found.contains(word) || word.isBlank()) + } + } + } + + @Test + fun closersStayWithTheirSentence() { + assertEquals("「猫だ。」", Sentences.around("「猫だ。」犬だ。", "猫")) + assertEquals("犬だ。", Sentences.around("「猫だ。」犬だ。", "犬")) + assertEquals("It rains!?", Sentences.around("Sun. It rains!? Yes.", "rains")) + assertEquals("two", Sentences.around("one\ntwo\nthree", "two")) + assertEquals("It is 3.5 km.", Sentences.around("Far. It is 3.5 km. Go.", "km")) + assertEquals("「猫だ」と言った。", Sentences.around("「猫だ」と言った。犬だ。", "猫")) + } + + @Test + fun theEmphasisKeepsTheText() = property { + checkAll(anyText, anyText.orNull(0.2)) { sentence, word -> + val html = Sentences.emphasize(sentence, word) + assertEquals(sentence, Sentences.unescape(Sentences.stripTags(html))) + if (!word.isNullOrEmpty() && word in sentence) assertTrue(html.contains("<b>" + Sentences.escape(word) + "</b>")) + } + } +} + +/** The clock of a player report. */ +class PlayerReportTest { + + private val report: Arb<PlayerReport> = arbitrary { + PlayerReport( + positionMs = Arb.long(0L..36_000_000L).bind(), + atNanos = Arb.long(0L..1_000_000_000_000L).bind(), + speed = Arb.element(listOf(0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0)).bind(), + playing = Arb.boolean().bind(), + ) + } + + @Test + fun theReportIsItsOwnAnchor() = property { + checkAll(report) { r -> + assertEquals(r.atNanos, r.wallAt(r.positionMs)) + assertEquals(r.positionMs, r.mediaAt(r.atNanos)) + } + } + + @Test + fun wallAndMediaAreInverse() = property { + checkAll(report, Arb.long(0L..36_000_000L)) { r, media -> + val back = r.mediaAt(r.wallAt(media)) + assertTrue("$media came back as $back", kotlin.math.abs(back - media) <= 1) + } + } + + @Test + fun aLaterMediaTimeIsALaterWallTime() = property { + checkAll(report, Arb.long(0L..36_000_000L), Arb.long(1L..600_000L)) { r, start, length -> + val range = r.wallRange(start, start + length) + assertTrue(range.last > range.first) + assertEquals(r.wallAt(start), range.first) + // The pad widens the range on both sides. + val padded = r.wallRange(start, start + length, padMs = 250) + assertTrue(padded.first < range.first && padded.last > range.last) + } + } + + @Test + fun theOverlayStateLineIsRead() { + val r = PlayerReport.parseOverlayState("playing=1;position=96153;speed=1.5;package=de.ph1b.audiobook", 7)!! + assertEquals(96153L, r.positionMs) + assertEquals(1.5, r.speed, 0.0) + assertTrue(r.playing) + assertEquals(7L, r.atNanos) + assertEquals("de.ph1b.audiobook", PlayerReport.overlayPackage("playing=1;position=96153;speed=1.5;package=de.ph1b.audiobook")) + assertNull(PlayerReport.parseOverlayState("error=no_player", 0)) + assertNull(PlayerReport.parseOverlayState("playing=0;speed=1.0;package=p", 0)) + assertNull(PlayerReport.parseOverlayState(null, 0)) + } +} + +/** The ring of captured sound. */ +class PcmRingTest { + + @Test + fun theLastFramesComeBackInOrder() = property { + checkAll(Arb.int(1..50), Arb.list(Arb.int(0..40), 1..30), Arb.int(1..2)) { capacity, chunks, channels -> + val rate = 100 + val ring = PcmRing(rate, channels, capacity) + val all = ArrayList<Short>() + var frames = 0L + for (chunk in chunks) { + val samples = ShortArray(chunk * channels) { (all.size + it).toShort() } + samples.forEach { all.add(it) } + frames += chunk + ring.write(samples, chunk, ring.framesToNanos(frames)) + } + val got = ring.read(Long.MIN_VALUE, Long.MAX_VALUE) + val held = minOf(frames, capacity.toLong()).toInt() + assertEquals(held * channels, got.size) + assertArrayEquals(all.takeLast(held * channels).toShortArray(), got) + assertEquals(ring.endNanos - ring.framesToNanos(held.toLong()), ring.startNanos) + } + } + + @Test + fun aRangeIsTheSliceOfTheWhole() = property { + checkAll(Arb.int(10..200), Arb.int(0..300), Arb.int(0..300)) { capacity, a, b -> + val rate = 1000 + val ring = PcmRing(rate, 1, capacity) + val total = capacity + 37 + // Written in one piece: frame i ends at (i + 1) ms. + ring.write(ShortArray(total) { it.toShort() }, total, ring.framesToNanos(total.toLong())) + val from = minOf(a, b).toLong() * 1_000_000L + val to = maxOf(a, b).toLong() * 1_000_000L + val got = ring.read(from, to) + val whole = ring.read(Long.MIN_VALUE, Long.MAX_VALUE) + val start = (ring.startNanos / 1_000_000L).toInt() + val lo = (maxOf(from, ring.startNanos) / 1_000_000L).toInt() - start + val hi = (minOf(to, ring.endNanos) / 1_000_000L).toInt() - start + val expected = if (hi > lo) whole.copyOfRange(lo, hi) else ShortArray(0) + assertArrayEquals(expected, got) + } + } + + @Test + fun anEmptyRingGivesNothing() { + val ring = PcmRing(16_000, 1, 16_000) + assertTrue(ring.isEmpty) + assertEquals(0, ring.read(0, 1_000_000_000).size) + } +} + +/** Filling the fields of a note type. */ +class FieldsTest { + + private val note = Note( + expression = "猫", reading = "ねこ", definition = "<b>cat</b>", sentence = "猫がいる。", + sentenceAudioFile = "s.m4a", wordAudioFile = "w.mp3", imageFile = "p.jpg", source = "A & B", pitch = "[1]", frequency = "12", + ) + + @Test + fun theLapisFieldsAreGuessed() { + val lapis = listOf( + "Expression", "ExpressionFurigana", "ExpressionReading", "ExpressionAudio", "SelectionText", "MainDefinition", + "DefinitionPicture", "Sentence", "SentenceFurigana", "SentenceAudio", "Picture", "Glossary", "Hint", + "IsWordAndSentenceCard", "IsClickCard", "IsSentenceCard", "IsAudioCard", "PitchPosition", "PitchCategories", + "Frequency", "FreqSort", "MiscInfo", + ) + val mapping = Fields.guess(lapis) + assertEquals(Source.EXPRESSION, mapping["Expression"]) + assertEquals(Source.FURIGANA, mapping["ExpressionFurigana"]) + assertEquals(Source.READING, mapping["ExpressionReading"]) + assertEquals(Source.WORD_AUDIO, mapping["ExpressionAudio"]) + assertEquals(Source.DEFINITION, mapping["MainDefinition"]) + assertEquals(Source.SENTENCE_BOLD, mapping["Sentence"]) + assertEquals(Source.SENTENCE_AUDIO, mapping["SentenceAudio"]) + assertEquals(Source.IMAGE, mapping["Picture"]) + assertEquals(Source.PITCH, mapping["PitchPosition"]) + assertEquals(Source.FREQUENCY, mapping["Frequency"]) + assertEquals(Source.SOURCE, mapping["MiscInfo"]) + // The second definition field stays empty: Glossary after MainDefinition. + assertEquals(Source.NONE, mapping["Glossary"]) + assertEquals(Source.NONE, mapping["IsSentenceCard"]) + assertEquals(Source.NONE, mapping["SentenceFurigana"]) + } + + @Test + fun aBasicNoteTypeGetsTheWordAndTheDefinition() { + val mapping = Fields.guess(listOf("Front", "Back")) + assertEquals(Source.EXPRESSION, mapping["Front"]) + assertEquals(Source.DEFINITION, mapping["Back"]) + val unknown = Fields.guess(listOf("Alpha", "Beta", "Gamma")) + assertEquals(Source.EXPRESSION, unknown["Alpha"]) + assertEquals(Source.DEFINITION, unknown["Beta"]) + assertEquals(Source.NONE, unknown["Gamma"]) + } + + @Test + fun theRenderHasOneValueForEachField() = property { + checkAll(Arb.list(Arb.stringPattern("[A-Za-z ]{1,12}"), 0..8)) { names -> + val values = Fields.render(note, names, Fields.guess(names)) + assertEquals(names.size, values.size) + } + } + + @Test + fun theValuesAreTheAnkiForms() { + assertEquals("[sound:s.m4a]", Fields.value(note, Source.SENTENCE_AUDIO)) + assertEquals("[sound:w.mp3]", Fields.value(note, Source.WORD_AUDIO)) + assertEquals("<img src=\"p.jpg\">", Fields.value(note, Source.IMAGE)) + assertEquals(" 猫[ねこ]", Fields.value(note, Source.FURIGANA)) + assertEquals("<b>猫</b>がいる。", Fields.value(note, Source.SENTENCE_BOLD)) + // The word is marked as it is in the sentence, not in its dictionary form. + val inflected = Note(expression = "食べる", sentence = "猫が食べた。", selection = "食べた") + assertEquals("猫が<b>食べた</b>。", Fields.value(inflected, Source.SENTENCE_BOLD)) + assertEquals("A & B", Fields.value(note, Source.SOURCE)) + assertEquals("x", Fields.value(note, Source.MARK)) + assertEquals("", Fields.value(Note(), Source.IMAGE)) + assertEquals("ねこ", Fields.furigana("ねこ", "ねこ")) + assertEquals("", Fields.furigana("", "ねこ")) + } + + @Test + fun theOwnNoteTypeIsComplete() { + assertEquals(NoteType.FIELDS.toSet(), NoteType.MAPPING.keys) + val rendered = Fields.render(note, NoteType.FIELDS, NoteType.MAPPING) + assertEquals(NoteType.FIELDS.size, rendered.size) + for (field in NoteType.FIELDS) { + assertTrue("$field is in the templates", (NoteType.QUESTION_FORMATS + NoteType.ANSWER_FORMATS).any { it.contains("{{$field}}") }) + } + assertEquals(NoteType.CARD_NAMES.size, NoteType.QUESTION_FORMATS.size) + assertEquals(NoteType.CARD_NAMES.size, NoteType.ANSWER_FORMATS.size) + } +} + +/** Names of media files. */ +class MediaNamesTest { + + @Test + fun theNameIsSafeAndShort() = property { + checkAll(anyText, Arb.long(0L..2_000_000_000_000L)) { expression, stamp -> + val name = MediaNames.name(expression, stamp, "m4a") + assertTrue(name, Regex("^subread_[\\p{L}\\p{N}_]+_[0-9]+\\.m4a$").matches(name)) + assertTrue(name.length <= "subread_".length + MediaNames.MAX_STEM + 1 + 13 + 4) + assertFalse(name.contains("__")) + } + } + + @Test + fun anEmptyStemIsCard() { + assertEquals("subread_card_5.jpg", MediaNames.name("...", 5, "jpg")) + assertEquals("subread_猫_5.mp3", MediaNames.name(" 猫 ", 5, "mp3")) + assertEquals("subread_a_b_5", MediaNames.stem("a b", 5)) + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt new file mode 100644 index 0000000..52485c5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -0,0 +1 @@ +First release. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..189a7ce --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,20 @@ +SubRead Anki makes an Anki card with one tap: the word, its reading and definition, the sentence it is in, the sound of that sentence, and a picture of the screen. The card goes into AnkiDroid on the device. It is made for people who read and listen in a language that they learn. + +<b>What it does</b> + +* A tap on a word of the subtitle line of SubRead Overlay makes the card: the line is the sentence, and its times cut the sound of the audiobook. +* "Anki" in the text selection menu of any app: a reader, a browser, an OCR app. Select a word, tap once. +* The share sheet: a text, or a picture for the last card. +* Another app, or a link on a web page, can send a whole card: word, sentence, audio and its times, picture. +* The reading, the definition, the pitch accent, the frequency and the word audio come from SubRead Dictionary. +* The capture keeps the last minute and a half of the sound of the device and the last picture of the screen, while it is on. A tile in the quick settings starts and stops it. +* Works with the Lapis note type, the basic note types, or its own. +* Black on white and nothing that moves: usable on an e-ink screen. + +<b>Permissions</b> + +* AnkiDroid: to add the cards. +* Screen capture and the microphone permission: for the sound of the player and the picture of the screen, only while the capture is on. Android asks each time it starts. No microphone is recorded. +* Internet: only for an audio or a picture at a URL that a sender gives. + +Nothing leaves the device. diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..92d9f4e --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +One tap makes an Anki card: word, definition, sentence, its sound, a picture. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..50a17d7 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +SubRead Anki \ No newline at end of file diff --git a/fdroid/space.subread.anki.yml b/fdroid/space.subread.anki.yml new file mode 100644 index 0000000..84ca8ca --- /dev/null +++ b/fdroid/space.subread.anki.yml @@ -0,0 +1,26 @@ +Categories: + - Science & Education +License: AGPL-3.0-only +AuthorName: equwal +SourceCode: https://github.com/equwal/subread-anki +IssueTracker: https://github.com/equwal/subread-anki/issues +Changelog: https://github.com/equwal/subread-anki/releases +Donate: https://ko-fi.com/truex + +AutoName: SubRead Anki + +RepoType: git +Repo: https://github.com/equwal/subread-anki.git + +Builds: + - versionName: 0.1.0 + versionCode: 1 + commit: v0.1.0 + subdir: app + gradle: + - yes + +AutoUpdateMode: Version +UpdateCheckMode: Tags +CurrentVersion: 0.1.0 +CurrentVersionCode: 1 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..0035b54 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.parallel=true +kotlin.code.style=official +android.useAndroidX=true diff --git a/gradle/libs.versions [diff truncated]