Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

git clone https://github.com/equwal/subread-anki

Log | Files | Refs


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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+
+    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("&amp;")
+                '<' -> append("&lt;")
+                '>' -> append("&gt;")
+                '"' -> append("&quot;")
+                else -> append(c)
+            }
+        }
+    }
+
+    /** The reverse of [escape]. */
+    fun unescape(html: String): String = html
+        .replace("&lt;", "<")
+        .replace("&gt;", ">")
+        .replace("&quot;", "\"")
+        .replace("&amp;", "&")
+
+    /** 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 &amp; 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]