Recently Written · git

ink-update

Ink Update: tells you when Rebind, Ink Recents or Ink Dim has a new version. F-Droid, then Google Play, then GitHub.

git clone https://github.com/equwal/ink-update

Log | Files | Refs


commit a37f8c7977ea38acd934bf57fe7afda120f3d593
truex <truex@equwal.com>
2026-09-21 12:35:42 -0700

Add Ink Update 0.1.0

The app watches Rebind and its extensions. It looks for a new version
on F-Droid, leaves an app that Google Play installed to Google Play,
and then looks on GitHub. It makes a notification and opens the page.
It does not install. It exists so that Rebind needs no internet
permission.

 .gitattributes                                     |   9 +
 .gitignore                                         |  23 +
 CHANGELOG.md                                       |  13 +
 LICENSE                                            | 674 +++++++++++++++++++++
 PRIVACY.md                                         |  46 ++
 README.md                                          | 112 ++++
 app/build.gradle.kts                               |  77 +++
 app/src/debug/AndroidManifest.xml                  |   9 +
 .../java/dev/equwal/inkupdate/debug/ShotApp.kt     |  54 ++
 app/src/main/AndroidManifest.xml                   |  60 ++
 app/src/main/java/dev/equwal/inkupdate/CheckJob.kt |  62 ++
 app/src/main/java/dev/equwal/inkupdate/Checker.kt  | 133 ++++
 app/src/main/java/dev/equwal/inkupdate/Decide.kt   | 201 ++++++
 .../main/java/dev/equwal/inkupdate/MainActivity.kt | 197 ++++++
 app/src/main/java/dev/equwal/inkupdate/Net.kt      |  73 +++
 app/src/main/java/dev/equwal/inkupdate/Notify.kt   |  74 +++
 app/src/main/java/dev/equwal/inkupdate/Store.kt    | 121 ++++
 app/src/main/java/dev/equwal/inkupdate/Ui.kt       | 343 +++++++++++
 app/src/main/java/dev/equwal/inkupdate/Versions.kt |  98 +++
 app/src/main/java/dev/equwal/inkupdate/Watched.kt  |  69 +++
 .../main/res/drawable/ic_launcher_foreground.xml   |  19 +
 app/src/main/res/drawable/ic_notify.xml            |  20 +
 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml |   6 +
 .../res/mipmap-anydpi-v26/ic_launcher_round.xml    |   6 +
 app/src/main/res/values/colors.xml                 |   4 +
 app/src/main/res/values/strings.xml                |  32 +
 app/src/main/res/values/styles.xml                 |  16 +
 .../test/java/dev/equwal/inkupdate/DecideTest.kt   | 159 +++++
 app/src/test/java/dev/equwal/inkupdate/JsonTest.kt | 167 +++++
 .../test/java/dev/equwal/inkupdate/VersionTest.kt  | 124 ++++
 build.gradle.kts                                   |   5 +
 fastlane/metadata/android/en-US/changelogs/1.txt   |   6 +
 .../metadata/android/en-US/full_description.txt    |  28 +
 .../metadata/android/en-US/short_description.txt   |   1 +
 fastlane/metadata/android/en-US/title.txt          |   1 +
 fdroid/README.md                                   |   8 +
 fdroid/dev.equwal.inkupdate.yml                    |  27 +
 gradle.properties                                  |   6 +
 gradle/wrapper/gradle-wrapper.jar                  | Bin 0 -> 47505 bytes
 gradle/wrapper/gradle-wrapper.properties           |   9 +
 gradlew                                            | 248 ++++++++
 gradlew.bat                                        |  82 +++
 settings.gradle.kts                                |  24 +
 43 files changed, 3446 insertions(+)
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b1d6ddb
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+# Keep line endings sane across platforms. gradlew must stay LF or it will not
+# run on macOS or Linux after a Windows checkout.
+* text=auto eol=lf
+gradlew text eol=lf
+*.bat text eol=crlf
+*.jar binary
+*.apk binary
+*.jks binary
+*.png binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..65589ae
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+# Build output
+build/
+.gradle/
+.kotlin/
+*.apk
+*.aab
+*.ap_
+*.dex
+
+# Local machine configuration
+local.properties
+build.ps1
+
+# Signing material. The keystore and its passwords never go in the repository.
+*.jks
+*.keystore
+keystore.properties
+
+# Editors
+.idea/
+*.iml
+.vscode/
+.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..632490e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+## 0.1.0
+
+First release.
+
+- Watches Rebind, Ink Recents, Ink Dim and Ink Update. It shows only the ones
+  that are installed.
+- Looks in F-Droid, then Google Play, then GitHub.
+- Makes one notification for each app that has an update. Tap it for the page,
+  or tap "Download" for the APK.
+- A daily check that lives through a restart. You can turn it off.
+- The intent action `dev.equwal.inkupdate.CHECK`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 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 General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..4509884
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,46 @@
+# Privacy
+
+Ink Update collects nothing.
+
+There is no account, no identifier, no advertisement and no analytics. The app
+sends nothing to me.
+
+## The two hosts it contacts
+
+The app makes one kind of request to each host.
+
+**f-droid.org**
+
+    https://f-droid.org/api/v1/packages/<package name>
+
+**api.github.com**
+
+    https://api.github.com/repos/<owner>/<name>/releases?per_page=10
+
+Each host sees what any web server sees: your IP address and the name in the
+address. The name is the package of a watched app, or the repository of a
+watched app. The request carries a User-Agent that says "InkUpdate" and the
+version. It carries no cookie and no account.
+
+The app makes these requests when you tap "Check now", and once a day while the
+daily check is on. It makes none of them for an app that Google Play installed.
+
+## What the app stores
+
+The app stores the result of the last check on your device: the version it
+found, the address of the page, and the time. It also stores whether the daily
+check is on, and which version it last told you about. Android deletes all of
+it when you uninstall the app.
+
+## The permissions
+
+- **INTERNET** and **ACCESS_NETWORK_STATE** make the two requests above.
+- **POST_NOTIFICATIONS** tells you that an update is ready.
+- **RECEIVE_BOOT_COMPLETED** keeps the daily check after you restart the
+  device.
+
+The app cannot install an app. It opens a page, and you decide.
+
+## Contact
+
+truex@equwal.com
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2f6f964
--- /dev/null
+++ b/README.md
@@ -0,0 +1,112 @@
+# Ink Update
+
+Watches Rebind and its extensions, and tells you when a new version is ready.
+
+The app does not install anything. It finds the new version, makes a
+notification, and opens the page. You install it.
+
+Made for e-ink readers and any Android 12 or later.
+
+## Screenshots
+
+To be added.
+
+## Why this is a separate app
+
+Rebind has no internet permission. This app has it, so Rebind does not need it.
+
+An app that looks for updates must reach the network. An app that reads your
+buttons must not. So the two jobs live in two apps. You can remove this one at
+any time, and Rebind keeps working.
+
+## What it watches
+
+- Rebind
+- Ink Recents
+- Ink Dim
+- Ink Update
+
+The app shows only the ones that are on your device.
+
+## Where it looks
+
+The app tries three places, in this order.
+
+1. **F-Droid.** The app asks F-Droid for the newest build of the package. If
+   F-Droid has a newer one, the app opens the F-Droid page. The F-Droid client
+   does the install.
+2. **Google Play.** Google Play has no public way to ask. So the app asks the
+   system which app did the install. If Google Play did the install, Google
+   Play updates the app, and Ink Update stops there. The row says
+   "Play updates this app". There is no notification.
+3. **GitHub.** The app reads the last ten releases of the repository and takes
+   the newest one that is not a draft. Every Rebind release is a pre-release,
+   so the app cannot use the "latest release" address.
+
+## What it sends
+
+Two hosts, and nothing else:
+
+    https://f-droid.org/api/v1/packages/<package>
+    https://api.github.com/repos/<owner>/<name>/releases?per_page=10
+
+Each request carries the package name or the repository name, and a User-Agent
+that names this app. There is no identifier, no account, no advertisement and
+no analytics. See [PRIVACY.md](PRIVACY.md).
+
+## Use
+
+- **Check now** looks at each installed app.
+- **Check once a day** keeps a daily check. It lives through a restart.
+- A row with an update opens the page for that app.
+- A notification says "Rebind 0.0.16-alpha is ready". Tap it for the page.
+  Tap **Download** for the APK.
+
+## Start it from somewhere else
+
+One intent action:
+
+    dev.equwal.inkupdate.CHECK
+
+Example with adb:
+
+    adb shell am start -a dev.equwal.inkupdate.CHECK
+
+Rebind can put this on a hardware button.
+
+## Install
+
+Download the APK from the Releases page and install it.
+
+Open the app once. It asks for the notification permission and does the first
+check.
+
+## More extensions
+
+[Awesome Rebind](https://github.com/equwal/awesome-rebind).
+
+## Say thanks
+
+Ink Update is free and open source. If it made your device better, you can
+[buy me a coffee](https://ko-fi.com/truex).
+
+## Build
+
+You need JDK 17 or later and the Android SDK, with platform 36.
+
+    ./gradlew testReleaseUnitTest assembleRelease
+
+The APK is in `app/build/outputs/apk/release/`.
+
+The APK has no dependency. The app uses `java.net.HttpURLConnection`, the
+`org.json` of the platform, and `JobScheduler`.
+
+Release signing is optional. Put a `keystore.properties` file in the root of
+the project with `storeFile`, `storePassword`, `keyAlias` and `keyPassword`.
+Without that file the build makes an unsigned APK.
+
+## Licence
+
+GPL-3.0-or-later. See [LICENSE](LICENSE).
+
+Copyright (c) 2026 equwal.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..5e48f76
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,77 @@
+import java.util.Properties
+
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+/**
+ * Release signing is optional. The keys come from keystore.properties in the
+ * root of the project, which is not in the repository. Without that file the
+ * release build still runs and makes an unsigned APK, so a fresh clone always
+ * builds.
+ */
+val keystoreProps = Properties().apply {
+    val f = rootProject.file("keystore.properties")
+    if (f.exists()) f.inputStream().use { load(it) }
+}
+val hasSigning = keystoreProps.getProperty("storeFile") != null
+
+android {
+    namespace = "dev.equwal.inkupdate"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "dev.equwal.inkupdate"
+        minSdk = 31
+        targetSdk = 36
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    signingConfigs {
+        if (hasSigning) {
+            create("release") {
+                storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
+                storePassword = keystoreProps.getProperty("storePassword")
+                keyAlias = keystoreProps.getProperty("keyAlias")
+                keyPassword = keystoreProps.getProperty("keyPassword")
+            }
+        }
+    }
+
+    buildTypes {
+        release {
+            // No shrinking: the app is small, and R8 adds nothing here.
+            isMinifyEnabled = false
+            if (hasSigning) signingConfig = signingConfigs.getByName("release")
+        }
+        debug {
+            applicationIdSuffix = ".debug"
+            versionNameSuffix = "-debug"
+        }
+    }
+
+    buildFeatures {
+        buildConfig = true
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+}
+
+// The APK has no dependency. The app uses java.net.HttpURLConnection for the
+// requests, org.json from the platform for the answers, and JobScheduler for
+// the daily check.
+dependencies {
+    // Tests only, never in the APK. android.jar gives the unit tests an
+    // org.json that throws, so the tests use the real one.
+    testImplementation("junit:junit:4.13.2")
+    testImplementation("org.json:json:20240303")
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..aa16e64
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Debug builds only. An e-ink panel can be driven outside SurfaceFlinger, and
+    then `adb shell screencap` gives a black frame. ShotApp has each screen draw
+    its own view tree to a PNG. None of this is in a release build.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application android:name=".debug.ShotApp" />
+</manifest>
diff --git a/app/src/debug/java/dev/equwal/inkupdate/debug/ShotApp.kt b/app/src/debug/java/dev/equwal/inkupdate/debug/ShotApp.kt
new file mode 100644
index 0000000..47baee0
--- /dev/null
+++ b/app/src/debug/java/dev/equwal/inkupdate/debug/ShotApp.kt
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate.debug
+
+import android.app.Activity
+import android.app.Application
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import java.io.File
+
+/**
+ * Screenshots for the README, for a device that cannot take them. Debug builds only.
+ *
+ * A second after any screen comes to the front, its view tree is drawn into a
+ * bitmap and written to the app's external files directory:
+ *
+ *   adb pull /sdcard/Android/data/dev.equwal.inkupdate.debug/files/shots
+ */
+class ShotApp : Application() {
+
+    override fun onCreate() {
+        super.onCreate()
+        val main = Handler(Looper.getMainLooper())
+        registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
+            override fun onActivityResumed(a: Activity) {
+                main.postDelayed({ if (!a.isFinishing) shoot(a) }, 2500L)
+            }
+            override fun onActivityCreated(a: Activity, b: Bundle?) = Unit
+            override fun onActivityStarted(a: Activity) = Unit
+            override fun onActivityPaused(a: Activity) = Unit
+            override fun onActivityStopped(a: Activity) = Unit
+            override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit
+            override fun onActivityDestroyed(a: Activity) = Unit
+        })
+    }
+
+    private fun shoot(a: Activity) {
+        val v = a.window.decorView
+        if (v.width == 0 || v.height == 0) return
+        val bmp = Bitmap.createBitmap(v.width, v.height, Bitmap.Config.ARGB_8888)
+        val canvas = Canvas(bmp)
+        canvas.drawColor(Color.WHITE)
+        v.draw(canvas)
+        val dir = File(getExternalFilesDir(null), "shots").apply { mkdirs() }
+        val out = File(dir, a.javaClass.simpleName + ".png")
+        out.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
+        Log.i("InkUpdate", "shot " + out.absolutePath)
+    }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..00283e6
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!--
+        Ink Update holds the internet permission so that the apps it watches do
+        not need it. Rebind has no internet permission, and it keeps none.
+    -->
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+    <!-- A persisted job needs this permission. The app has no receiver. -->
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+    <!--
+        From Android 11 an app sees only the packages it names. The app must see
+        the apps it watches, to read the installed version, and the two stores,
+        to open a page in them.
+    -->
+    <queries>
+        <package android:name="dev.equwal.assistkey" />
+        <package android:name="dev.equwal.inkrecents" />
+        <package android:name="dev.equwal.inkdim" />
+        <package android:name="dev.equwal.inkupdate" />
+        <package android:name="org.fdroid.fdroid" />
+        <package android:name="com.android.vending" />
+    </queries>
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.App">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:label="@string/app_name"
+            android:launchMode="singleTask">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+            <!-- For Rebind, or any other app that asks for a check. -->
+            <intent-filter>
+                <action android:name="dev.equwal.inkupdate.CHECK" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <!-- The daily check. The system starts it. -->
+        <service
+            android:name=".CheckJob"
+            android:exported="false"
+            android:permission="android.permission.BIND_JOB_SERVICE" />
+
+    </application>
+
+</manifest>
diff --git a/app/src/main/java/dev/equwal/inkupdate/CheckJob.kt b/app/src/main/java/dev/equwal/inkupdate/CheckJob.kt
new file mode 100644
index 0000000..d1462b5
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/CheckJob.kt
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import android.app.job.JobInfo
+import android.app.job.JobParameters
+import android.app.job.JobScheduler
+import android.app.job.JobService
+import android.content.ComponentName
+import android.content.Context
+
+/**
+ * The daily check.
+ *
+ * The job is persisted, so it lives through a restart of the device. That is
+ * what the RECEIVE_BOOT_COMPLETED permission is for: the app has no receiver.
+ * The system decides the exact minute, and it waits for a network.
+ */
+class CheckJob : JobService() {
+
+    override fun onStartJob(params: JobParameters?): Boolean {
+        Thread {
+            val store = Store(applicationContext)
+            try {
+                Checker.checkAll(applicationContext, store)
+            } catch (e: Exception) {
+                store.checked(System.currentTimeMillis(), false)
+            }
+            jobFinished(params, false)
+        }.start()
+        // True: the work goes on after this function returns.
+        return true
+    }
+
+    override fun onStopJob(params: JobParameters?): Boolean = true
+
+    companion object {
+
+        private const val ID = 1
+        private const val DAY_MS = 24L * 60L * 60L * 1000L
+
+        /** Starts the daily job. A second call replaces the first. */
+        fun schedule(context: Context) {
+            val s = context.getSystemService(JobScheduler::class.java) ?: return
+            val job = JobInfo.Builder(ID, ComponentName(context, CheckJob::class.java))
+                .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+                .setPeriodic(DAY_MS)
+                .setPersisted(true)
+                .build()
+            try {
+                s.schedule(job)
+            } catch (e: Exception) {
+                // A device can refuse the job. The "Check now" button still works.
+            }
+        }
+
+        /** Stops the daily job. */
+        fun cancel(context: Context) {
+            context.getSystemService(JobScheduler::class.java)?.cancel(ID)
+        }
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Checker.kt b/app/src/main/java/dev/equwal/inkupdate/Checker.kt
new file mode 100644
index 0000000..02ed24a
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Checker.kt
@@ -0,0 +1,133 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import android.content.Context
+import android.content.pm.PackageManager
+
+/**
+ * The check itself: what is installed, what the two hosts say, and what that
+ * means.
+ *
+ * Every function here makes network requests. Call them from a background
+ * thread only.
+ */
+object Checker {
+
+    private const val GITHUB_ACCEPT = "application/vnd.github+json"
+
+    /** A watched app that is on the device. */
+    data class Installed(
+        val watched: Watched,
+        val versionName: String,
+        val versionCode: Long,
+        val installer: String?
+    )
+
+    /** A row of the screen: an installed app and what the last check found. */
+    data class Row(val app: Installed, val result: Result?)
+
+    /** The watched apps that are on the device, in the order of the list. */
+    fun installed(context: Context): List<Installed> {
+        val pm = context.packageManager
+        val out = ArrayList<Installed>()
+        for (w in Watch.ALL) {
+            val info = try {
+                pm.getPackageInfo(w.pkg, 0)
+            } catch (e: PackageManager.NameNotFoundException) {
+                null
+            } catch (e: Exception) {
+                null
+            } ?: continue
+            out.add(
+                Installed(
+                    watched = w,
+                    versionName = info.versionName ?: "",
+                    versionCode = info.longVersionCode,
+                    installer = installerOf(pm, w.pkg)
+                )
+            )
+        }
+        return out
+    }
+
+    /** The rows to show, from what the store holds. This makes no request. */
+    fun rows(context: Context, store: Store): List<Row> =
+        installed(context).map { Row(it, store.result(it.watched.pkg)) }
+
+    /**
+     * Checks every installed watched app, keeps the results, and tells the
+     * person about each new update.
+     *
+     * @return how many apps have an update.
+     */
+    fun checkAll(context: Context, store: Store): Int {
+        val apps = installed(context)
+        var updates = 0
+        var ok = true
+        for (app in apps) {
+            val r = check(app)
+            if (r is Result.Unknown) ok = false
+            store.put(app.watched.pkg, r)
+            if (r is Result.Update) {
+                updates++
+                // The version is marked only after the notification went out.
+                // A person who turns notifications on later still hears about
+                // the update at the next check.
+                if (store.notified(app.watched.pkg) != r.versionName &&
+                    Notify.update(context, app.watched, r)
+                ) {
+                    store.setNotified(app.watched.pkg, r.versionName)
+                }
+            }
+        }
+        store.checked(System.currentTimeMillis(), ok)
+        return updates
+    }
+
+    /** The check of one app: F-Droid, then the Play store, then GitHub. */
+    fun check(app: Installed): Result {
+        val w = app.watched
+        val fdroid = askFdroid(w)
+        val fdroidWins = fdroid is FdroidAnswer.Found && fdroid.versionCode > app.versionCode
+
+        // Two cases make the GitHub request waste: F-Droid already has a newer
+        // build, and the Play store updates the app by itself.
+        val github =
+            if (fdroidWins || app.installer == Decide.PLAY_PACKAGE) GithubAnswer.None
+            else askGithub(w)
+
+        return Decide.source(
+            app.installer, fdroid, github, app.versionCode, app.versionName
+        )
+    }
+
+    private fun askFdroid(w: Watched): FdroidAnswer {
+        val url = w.fdroidApi() ?: return FdroidAnswer.NotThere
+        val page = w.fdroidPage() ?: return FdroidAnswer.NotThere
+        val a = Net.get(url)
+        return when {
+            a.code == 0 -> FdroidAnswer.Failed("No answer")
+            a.code == 404 -> FdroidAnswer.NotThere
+            a.code !in 200..299 -> FdroidAnswer.Failed("F-Droid said " + a.code)
+            else -> Decide.parseFdroid(a.body, page)
+        }
+    }
+
+    private fun askGithub(w: Watched): GithubAnswer {
+        val a = Net.get(w.githubApi(), GITHUB_ACCEPT)
+        return when {
+            a.code == 0 -> GithubAnswer.Failed("No answer")
+            a.code == 403 || a.code == 429 -> GithubAnswer.Failed("Too many requests")
+            a.code !in 200..299 -> GithubAnswer.Failed("GitHub said " + a.code)
+            else -> Decide.parseGithub(a.body, w.preferAsset)
+        }
+    }
+
+    /** The package that installed the app, as the system knows it. */
+    private fun installerOf(pm: PackageManager, pkg: String): String? = try {
+        pm.getInstallSourceInfo(pkg).installingPackageName
+    } catch (e: Exception) {
+        null
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Decide.kt b/app/src/main/java/dev/equwal/inkupdate/Decide.kt
new file mode 100644
index 0000000..7fb02c4
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Decide.kt
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** What F-Droid said about one app. */
+sealed class FdroidAnswer {
+
+    /** F-Droid does not have the app. */
+    object NotThere : FdroidAnswer()
+
+    /** F-Droid has the app. [page] is the page for a person to open. */
+    data class Found(
+        val versionCode: Long,
+        val versionName: String,
+        val page: String
+    ) : FdroidAnswer()
+
+    /** The request failed, or the answer made no sense. */
+    data class Failed(val reason: String) : FdroidAnswer()
+}
+
+/** What GitHub said about one repository. */
+sealed class GithubAnswer {
+
+    /** The repository has no release that a person can get. */
+    object None : GithubAnswer()
+
+    /**
+     * The newest release that is not a draft. Every release of these
+     * repositories is a pre-release, so the app must not use /releases/latest.
+     */
+    data class Found(
+        val tag: String,
+        val page: String,
+        val apk: String?
+    ) : GithubAnswer()
+
+    /** The request failed, or the answer made no sense. */
+    data class Failed(val reason: String) : GithubAnswer()
+}
+
+/** What the app knows about one watched app after a check. */
+sealed class Result {
+
+    /** The installed version is the newest one found. */
+    object UpToDate : Result()
+
+    /** A newer version is there. [page] opens it, [apk] downloads it. */
+    data class Update(
+        val source: String,
+        val versionName: String,
+        val page: String,
+        val apk: String?
+    ) : Result()
+
+    /** The Play store installed the app, so the Play store updates it. */
+    object LeftToPlay : Result()
+
+    /** Nobody answered. */
+    data class Unknown(val reason: String) : Result()
+}
+
+/**
+ * The order of the search, and the reading of the two answers.
+ *
+ * Nothing here touches Android, so the unit tests run on the JVM.
+ */
+object Decide {
+
+    const val FDROID = "F-Droid"
+    const val GITHUB = "GitHub"
+    const val PLAY = "Google Play"
+
+    /** The package name of the Play store client. */
+    const val PLAY_PACKAGE = "com.android.vending"
+
+    /**
+     * Where the update comes from. The order is F-Droid, Google Play, GitHub.
+     *
+     * 1. F-Droid holds a newer build: take it. The F-Droid client updates the
+     *    app by itself after the person opens the page once.
+     * 2. The Play store installed the app: the Play store updates it. Stop.
+     * 3. GitHub holds a newer release: take it.
+     *
+     * @param installer the package that installed the app, from the system.
+     */
+    fun source(
+        installer: String?,
+        fdroid: FdroidAnswer,
+        github: GithubAnswer,
+        installedCode: Long,
+        installedName: String
+    ): Result {
+        if (fdroid is FdroidAnswer.Found && fdroid.versionCode > installedCode) {
+            return Result.Update(FDROID, fdroid.versionName, fdroid.page, null)
+        }
+        if (installer == PLAY_PACKAGE) return Result.LeftToPlay
+
+        if (github is GithubAnswer.Found) {
+            val theirs = Version.parse(github.tag)
+            val ours = Version.parse(installedName)
+            if (theirs == null || ours == null) {
+                return Result.Unknown("Version not understood")
+            }
+            return if (theirs > ours) {
+                Result.Update(GITHUB, Version.display(github.tag), github.page, github.apk)
+            } else {
+                Result.UpToDate
+            }
+        }
+
+        // GitHub gave no release. F-Droid answered, so the app is up to date.
+        if (fdroid is FdroidAnswer.Found) return Result.UpToDate
+        if (github is GithubAnswer.Failed) return Result.Unknown(github.reason)
+        if (fdroid is FdroidAnswer.Failed) return Result.Unknown(fdroid.reason)
+
+        // Neither place has the app, so there is nothing newer to get.
+        return Result.UpToDate
+    }
+
+    /**
+     * Reads the answer of https://f-droid.org/api/v1/packages/<id>.
+     *
+     * The answer {"error":"NOT_FOUND"} means that F-Droid does not have the
+     * app. That is not a failure.
+     *
+     * This function never throws.
+     */
+    fun parseFdroid(body: String?, page: String): FdroidAnswer {
+        if (body.isNullOrBlank()) return FdroidAnswer.Failed("Empty answer")
+        return try {
+            val o = JSONObject(body)
+            if (o.has("error")) return FdroidAnswer.NotThere
+            if (!o.has("suggestedVersionCode")) return FdroidAnswer.Failed("No version")
+            val code = o.getLong("suggestedVersionCode")
+            val packages = o.optJSONArray("packages") ?: JSONArray()
+            var name = ""
+            for (i in 0 until packages.length()) {
+                val p = packages.optJSONObject(i) ?: continue
+                if (p.optLong("versionCode", -1L) == code) {
+                    name = p.optString("versionName", "")
+                    break
+                }
+            }
+            if (name.isEmpty() && packages.length() > 0) {
+                name = packages.optJSONObject(0)?.optString("versionName", "") ?: ""
+            }
+            FdroidAnswer.Found(code, name, page)
+        } catch (e: Exception) {
+            FdroidAnswer.Failed("Answer not understood")
+        }
+    }
+
+    /**
+     * Reads the answer of
+     * https://api.github.com/repos/<owner>/<repo>/releases?per_page=10.
+     *
+     * The first entry that is not a draft wins. A pre-release counts, because
+     * every release of these repositories is a pre-release.
+     *
+     * @param preferAsset a word in the name of the APK to take first.
+     *
+     * This function never throws.
+     */
+    fun parseGithub(body: String?, preferAsset: String? = null): GithubAnswer {
+        if (body.isNullOrBlank()) return GithubAnswer.Failed("Empty answer")
+        return try {
+            val list = JSONArray(body)
+            for (i in 0 until list.length()) {
+                val r = list.optJSONObject(i) ?: continue
+                if (r.optBoolean("draft", false)) continue
+                val tag = r.optString("tag_name", "")
+                if (tag.isEmpty()) return GithubAnswer.Failed("No tag")
+                val page = r.optString("html_url", "")
+                return GithubAnswer.Found(tag, page, apkOf(r, preferAsset))
+            }
+            GithubAnswer.None
+        } catch (e: Exception) {
+            GithubAnswer.Failed("Answer not understood")
+        }
+    }
+
+    /** The APK of a release, or null. A name that holds [prefer] wins. */
+    private fun apkOf(release: JSONObject, prefer: String?): String? {
+        val assets = release.optJSONArray("assets") ?: return null
+        var first: String? = null
+        for (i in 0 until assets.length()) {
+            val a = assets.optJSONObject(i) ?: continue
+            val name = a.optString("name", "").lowercase()
+            if (!name.endsWith(".apk")) continue
+            val url = a.optString("browser_download_url", "")
+            if (url.isEmpty()) continue
+            if (prefer != null && name.contains(prefer.lowercase())) return url
+            if (first == null) first = url
+        }
+        return first
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/MainActivity.kt b/app/src/main/java/dev/equwal/inkupdate/MainActivity.kt
new file mode 100644
index 0000000..f9f7cf7
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/MainActivity.kt
@@ -0,0 +1,197 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import android.Manifest
+import android.app.Activity
+import android.app.NotificationManager
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Bundle
+import android.text.format.DateUtils
+import android.widget.LinearLayout
+import dev.equwal.inkupdate.Ui.button
+import dev.equwal.inkupdate.Ui.header
+import dev.equwal.inkupdate.Ui.note
+import dev.equwal.inkupdate.Ui.page
+import dev.equwal.inkupdate.Ui.primaryButton
+import dev.equwal.inkupdate.Ui.row
+import dev.equwal.inkupdate.Ui.title
+
+/**
+ * The one screen.
+ *
+ * It shows each installed watched app, what is installed, and what the last
+ * check found. The check runs on a background thread, because the main thread
+ * must not wait for the network.
+ */
+class MainActivity : Activity() {
+
+    /** Another app asks for a check with this action. Rebind uses it. */
+    private val checkAction = "dev.equwal.inkupdate.CHECK"
+
+    private lateinit var store: Store
+    private var checking = false
+
+    override fun onCreate(saved: Bundle?) {
+        super.onCreate(saved)
+        store = Store(this)
+
+        val first = !store.started
+        if (first) {
+            store.started = true
+            if (store.daily) CheckJob.schedule(this)
+            ask()
+        }
+        draw()
+        if (first || intent?.action == checkAction) check()
+    }
+
+    override fun onNewIntent(i: Intent?) {
+        super.onNewIntent(i)
+        intent = i
+        if (i?.action == checkAction) check()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        if (!checking) draw()
+    }
+
+    // ---- the screen --------------------------------------------------------
+
+    private fun draw() {
+        val col = page(this)
+        col.title(getString(R.string.app_name))
+        col.note(status())
+
+        val rows = Checker.rows(this, store)
+        if (rows.isEmpty()) {
+            col.note(getString(R.string.none_installed))
+        } else {
+            for (r in rows) appRow(col, r)
+        }
+
+        col.primaryButton(
+            if (checking) getString(R.string.checking) else getString(R.string.check_now)
+        ) { check() }
+
+        col.row(
+            title = getString(R.string.daily),
+            state = if (store.daily) getString(R.string.on) else getString(R.string.off)
+        ) { setDaily(!store.daily) }
+
+        if (!notificationsOn()) {
+            col.row(
+                title = getString(R.string.notifications),
+                state = getString(R.string.off)
+            ) { ask() }
+        }
+
+        col.header(getString(R.string.about))
+        col.row(title = getString(R.string.version), subtitle = BuildConfig.VERSION_NAME)
+        col.row(title = getString(R.string.licence), subtitle = "GPL-3.0-or-later")
+        col.row(title = getString(R.string.source_code)) { open(SOURCE) }
+        col.row(title = getString(R.string.coffee)) { open(KOFI) }
+    }
+
+    /** One watched app: what is installed, and what the check found. */
+    private fun appRow(col: LinearLayout, r: Checker.Row) {
+        val result = r.result
+        val state = when (result) {
+            is Result.Update -> getString(R.string.update_to, result.versionName)
+            is Result.UpToDate -> getString(R.string.up_to_date)
+            is Result.LeftToPlay -> getString(R.string.play_updates)
+            else -> getString(R.string.no_answer)
+        }
+        val page = when (result) {
+            is Result.Update -> result.page
+            is Result.LeftToPlay -> r.app.watched.playPage()
+            else -> null
+        }
+        col.row(
+            title = r.app.watched.title,
+            subtitle = r.app.versionName,
+            state = state,
+            onClick = if (page == null) null else ({ open(page) })
+        )
+    }
+
+    /** The one line that says when the app last looked. */
+    private fun status(): String = when {
+        checking -> getString(R.string.checking)
+        store.checkedAt == 0L -> getString(R.string.never_checked)
+        else -> {
+            val ago = DateUtils.getRelativeTimeSpanString(
+                store.checkedAt, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS
+            ).toString()
+            if (store.lastOk) getString(R.string.checked, ago)
+            else getString(R.string.could_not_check, ago)
+        }
+    }
+
+    // ---- the work ----------------------------------------------------------
+
+    private fun check() {
+        if (checking) return
+        checking = true
+        draw()
+        Thread {
+            try {
+                Checker.checkAll(applicationContext, store)
+            } catch (e: Exception) {
+                store.checked(System.currentTimeMillis(), false)
+            }
+            runOnUiThread {
+                checking = false
+                if (!isFinishing && !isDestroyed) draw()
+            }
+        }.start()
+    }
+
+    private fun setDaily(on: Boolean) {
+        store.daily = on
+        if (on) CheckJob.schedule(this) else CheckJob.cancel(this)
+        draw()
+    }
+
+    // ---- notifications -----------------------------------------------------
+
+    private fun notificationsOn(): Boolean =
+        getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() ?: false
+
+    /** Asks for the notification permission. Android shows the box once. */
+    private fun ask() {
+        if (notificationsOn()) return
+        if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+            != PackageManager.PERMISSION_GRANTED
+        ) {
+            requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
+        }
+    }
+
+    override fun onRequestPermissionsResult(
+        code: Int,
+        permissions: Array<out String>,
+        results: IntArray
+    ) {
+        super.onRequestPermissionsResult(code, permissions, results)
+        if (!checking) draw()
+    }
+
+    // ---- links -------------------------------------------------------------
+
+    private fun open(url: String) {
+        try {
+            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+        } catch (e: Exception) {
+            // No app takes the link. There is nothing to do about it.
+        }
+    }
+
+    private companion object {
+        const val SOURCE = "https://github.com/equwal/ink-update"
+        const val KOFI = "https://ko-fi.com/truex"
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Net.kt b/app/src/main/java/dev/equwal/inkupdate/Net.kt
new file mode 100644
index 0000000..ccaaca8
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Net.kt
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * One HTTP GET, with java.net and nothing else.
+ *
+ * The app sends no identifier and no cookie. It sends the package name or the
+ * repository name in the path, and a User-Agent that names the app. Call this
+ * from a background thread only.
+ */
+object Net {
+
+    const val AGENT = "InkUpdate/" + BuildConfig.VERSION_NAME +
+        " (+https://github.com/equwal/ink-update)"
+
+    private const val CONNECT_MS = 10_000
+    private const val READ_MS = 15_000
+
+    /** The most bytes to read from one answer. An answer is a few kilobytes. */
+    private const val MAX_BYTES = 512 * 1024
+
+    /**
+     * @param code the HTTP status, or 0 when the request did not finish.
+     * @param body the text of the answer, or null.
+     */
+    data class Answer(val code: Int, val body: String?)
+
+    fun get(url: String, accept: String? = null): Answer {
+        var c: HttpURLConnection? = null
+        return try {
+            c = (URL(url).openConnection() as HttpURLConnection).apply {
+                requestMethod = "GET"
+                connectTimeout = CONNECT_MS
+                readTimeout = READ_MS
+                instanceFollowRedirects = true
+                setRequestProperty("User-Agent", AGENT)
+                if (accept != null) setRequestProperty("Accept", accept)
+            }
+            val code = c.responseCode
+            // F-Droid answers 404 with a body that says NOT_FOUND, so the error
+            // stream matters as much as the input stream.
+            val stream: InputStream? = if (code in 200..299) c.inputStream else c.errorStream
+            Answer(code, stream?.let { read(it) })
+        } catch (e: Exception) {
+            Answer(0, null)
+        } finally {
+            c?.disconnect()
+        }
+    }
+
+    // The bytes go into one buffer and are made into text once. A character of
+    // more than one byte can lie across two reads.
+    private fun read(stream: InputStream): String? = try {
+        stream.use { s ->
+            val out = ByteArrayOutputStream()
+            val buf = ByteArray(8 * 1024)
+            while (out.size() < MAX_BYTES) {
+                val n = s.read(buf)
+                if (n <= 0) break
+                out.write(buf, 0, n)
+            }
+            String(out.toByteArray(), Charsets.UTF_8)
+        }
+    } catch (e: Exception) {
+        null
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Notify.kt b/app/src/main/java/dev/equwal/inkupdate/Notify.kt
new file mode 100644
index 0000000..52dc726
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Notify.kt
@@ -0,0 +1,74 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+
+/**
+ * One notification for each app that has an update.
+ *
+ * The id comes from the package name, so a later check replaces the old
+ * notification instead of adding a second one. The caller tells a person about
+ * a version once. Nothing else makes a notification.
+ */
+object Notify {
+
+    private const val CHANNEL = "updates"
+
+    /**
+     * Tells the person about one update.
+     *
+     * @return true when the notification went out. It does not go out while
+     *         the person keeps notifications off. The caller must then ask
+     *         again after the next check, so the update is not lost.
+     */
+    fun update(context: Context, w: Watched, r: Result.Update): Boolean {
+        val m = context.getSystemService(NotificationManager::class.java) ?: return false
+        if (!m.areNotificationsEnabled()) return false
+        channel(m, context)
+
+        val b = Notification.Builder(context, CHANNEL)
+            .setSmallIcon(R.drawable.ic_notify)
+            .setContentTitle(w.title + " " + r.versionName + " is ready")
+            .setContentText("From " + r.source)
+            .setAutoCancel(true)
+            .setContentIntent(open(context, r.page, w.pkg.hashCode()))
+
+        if (r.apk != null) {
+            b.addAction(
+                Notification.Action.Builder(
+                    null as android.graphics.drawable.Icon?,
+                    "Download",
+                    open(context, r.apk, w.pkg.hashCode() + 1)
+                ).build()
+            )
+        }
+        m.notify(w.pkg.hashCode(), b.build())
+        return true
+    }
+
+    private fun channel(m: NotificationManager, context: Context) {
+        m.createNotificationChannel(
+            NotificationChannel(
+                CHANNEL,
+                context.getString(R.string.channel_updates),
+                NotificationManager.IMPORTANCE_DEFAULT
+            )
+        )
+    }
+
+    /** Opens a page in whatever app handles it: F-Droid, Play, or a browser. */
+    private fun open(context: Context, url: String, id: Int): PendingIntent {
+        val i = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        return PendingIntent.getActivity(
+            context, id, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Store.kt b/app/src/main/java/dev/equwal/inkupdate/Store.kt
new file mode 100644
index 0000000..ec04351
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Store.kt
@@ -0,0 +1,121 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import android.content.Context
+import org.json.JSONObject
+
+/**
+ * What the app remembers between checks: the last result for each app, the
+ * time of the last check, the daily switch, and the version of the last
+ * notification.
+ *
+ * The results are one JSON object in the shared preferences. A failed check
+ * keeps the last good result, so a bad network does not hide a known update.
+ */
+class Store(context: Context) {
+
+    private val prefs = context.applicationContext
+        .getSharedPreferences("inkupdate", Context.MODE_PRIVATE)
+
+    // ---- the daily job -----------------------------------------------------
+
+    /** True while the daily check is on. It is on until the person turns it off. */
+    var daily: Boolean
+        get() = prefs.getBoolean(DAILY, true)
+        set(v) = prefs.edit().putBoolean(DAILY, v).apply()
+
+    /** True after the first start did its work. */
+    var started: Boolean
+        get() = prefs.getBoolean(STARTED, false)
+        set(v) = prefs.edit().putBoolean(STARTED, v).apply()
+
+    // ---- the last check ----------------------------------------------------
+
+    /** When the last check ran, in milliseconds. 0 means never. */
+    val checkedAt: Long get() = prefs.getLong(CHECKED_AT, 0L)
+
+    /** True when every request of the last check gave an answer. */
+    val lastOk: Boolean get() = prefs.getBoolean(LAST_OK, true)
+
+    fun checked(at: Long, ok: Boolean) {
+        prefs.edit().putLong(CHECKED_AT, at).putBoolean(LAST_OK, ok).apply()
+    }
+
+    // ---- the results -------------------------------------------------------
+
+    /**
+     * Keeps the result of one app. A [Result.Unknown] does not replace a result
+     * that the app already has.
+     */
+    fun put(pkg: String, r: Result) {
+        if (r is Result.Unknown && result(pkg) != null) return
+        val all = results()
+        all.put(pkg, write(r))
+        prefs.edit().putString(RESULTS, all.toString()).apply()
+    }
+
+    /** The last result of one app, or null when there is none. */
+    fun result(pkg: String): Result? {
+        val o = results().optJSONObject(pkg) ?: return null
+        return read(o)
+    }
+
+    // ---- what the app told the person --------------------------------------
+
+    /** The version of the last notification for one app. */
+    fun notified(pkg: String): String? = prefs.getString(NOTIFIED + pkg, null)
+
+    fun setNotified(pkg: String, versionName: String) {
+        prefs.edit().putString(NOTIFIED + pkg, versionName).apply()
+    }
+
+    // ---- JSON --------------------------------------------------------------
+
+    private fun results(): JSONObject = try {
+        JSONObject(prefs.getString(RESULTS, "{}") ?: "{}")
+    } catch (e: Exception) {
+        JSONObject()
+    }
+
+    private fun write(r: Result): JSONObject = JSONObject().apply {
+        when (r) {
+            is Result.Update -> {
+                put(KIND, "update")
+                put("source", r.source)
+                put("version", r.versionName)
+                put("page", r.page)
+                if (r.apk != null) put("apk", r.apk)
+            }
+            is Result.UpToDate -> put(KIND, "ok")
+            is Result.LeftToPlay -> put(KIND, "play")
+            is Result.Unknown -> {
+                put(KIND, "unknown")
+                put("reason", r.reason)
+            }
+        }
+    }
+
+    private fun read(o: JSONObject): Result? = when (o.optString(KIND)) {
+        "update" -> Result.Update(
+            o.optString("source"),
+            o.optString("version"),
+            o.optString("page"),
+            if (o.has("apk")) o.optString("apk") else null
+        )
+        "ok" -> Result.UpToDate
+        "play" -> Result.LeftToPlay
+        "unknown" -> Result.Unknown(o.optString("reason"))
+        else -> null
+    }
+
+    private companion object {
+        const val DAILY = "daily"
+        const val STARTED = "started"
+        const val CHECKED_AT = "checkedAt"
+        const val LAST_OK = "lastOk"
+        const val RESULTS = "results"
+        const val NOTIFIED = "notified."
+        const val KIND = "kind"
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Ui.kt b/app/src/main/java/dev/equwal/inkupdate/Ui.kt
new file mode 100644
index 0000000..741008e
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Ui.kt
@@ -0,0 +1,343 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.text.TextUtils
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.view.WindowInsets
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+
+/**
+ * View construction, in code, with no support library.
+ *
+ * The screen is e-ink: no colour, slow refresh, and a hard time with light
+ * greys. So everything here is black on white at generous sizes, an outline is
+ * a real black line, and nothing animates.
+ *
+ * The rules this file keeps, so that each screen looks the same:
+ *
+ *  - 16 dp side margins, and spacing on an 8 dp grid.
+ *  - A row is at least 56 dp high, and the whole row is the touch target.
+ *  - Titles are 17 sp, notes 14 sp, section headers 13 sp small caps.
+ *  - Secondary text is #444444. Nothing lighter carries a fact.
+ *  - A button is 48 dp high and outlined. The main button is 56 dp and filled.
+ */
+object Ui {
+
+    const val INK = Color.BLACK
+
+    /** Secondary text. The lightest grey that still reads on e-ink. */
+    val DIM = Color.rgb(68, 68, 68)
+
+    /** Separator lines between rows. */
+    val RULE = Color.rgb(150, 150, 150)
+
+    fun Context.dp(v: Int): Int = TypedValue.applyDimension(
+        TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics
+    ).toInt()
+
+    private fun Context.px(v: Int): Int = maxOf(1, dp(v))
+
+    // ---- page --------------------------------------------------------------
+
+    /**
+     * Root of a screen. It gives back the column that the caller fills.
+     *
+     * With a [title] the screen gets the standard top bar: a back arrow with a
+     * 48 dp touch target, the title, and a rule under it. The content below
+     * scrolls only when it does not fit.
+     */
+    fun page(a: Activity, title: String? = null): LinearLayout {
+        val root = LinearLayout(a).apply {
+            orientation = LinearLayout.VERTICAL
+            setBackgroundColor(Color.WHITE)
+        }
+        if (title != null) {
+            root.addView(topBar(a, title))
+            root.addView(View(a).apply {
+                setBackgroundColor(RULE)
+                layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
+            })
+        }
+
+        val col = LinearLayout(a).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(a.dp(16), a.dp(if (title == null) 8 else 12), a.dp(16), a.dp(24))
+        }
+        val scroll = ScrollView(a).apply {
+            isFillViewport = true
+            clipToPadding = false
+            addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
+        }
+        root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
+
+        // Edge to edge is enforced from Android 15, so the system bars overlap
+        // the window. The top bar moves down by what the status bar covers, and
+        // the content keeps clear of the navigation bar.
+        root.setOnApplyWindowInsetsListener { v, insets ->
+            val bars = insets.getInsets(
+                WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()
+            )
+            v.setPadding(bars.left, bars.top, bars.right, 0)
+            col.setPadding(
+                col.paddingLeft, col.paddingTop, col.paddingRight, a.dp(24) + bars.bottom
+            )
+            insets
+        }
+        a.setContentView(root)
+        root.requestApplyInsets()
+        return col
+    }
+
+    private fun topBar(a: Activity, text: String): View {
+        val bar = LinearLayout(a).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            setPadding(a.dp(4), 0, a.dp(16), 0)
+            layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, a.dp(56))
+        }
+        bar.addView(Glyph(a, Glyph.BACK).apply {
+            contentDescription = "Back"
+            isClickable = true
+            isFocusable = true
+            @Suppress("DEPRECATION")
+            setOnClickListener { a.onBackPressed() }
+            layoutParams = LinearLayout.LayoutParams(a.dp(48), a.dp(48))
+        })
+        bar.addView(TextView(a).apply {
+            this.text = text
+            setTextColor(INK)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
+            setTypeface(Typeface.DEFAULT_BOLD)
+            maxLines = 1
+            ellipsize = TextUtils.TruncateAt.END
+            layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f).apply {
+                marginStart = a.dp(4)
+            }
+        })
+        return bar
+    }
+
+    // ---- text --------------------------------------------------------------
+
+    /** The name of a screen that has no back arrow, because it is the first one. */
+    fun LinearLayout.title(text: String) = add(TextView(context).apply {
+        this.text = text
+        setTextColor(INK)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
+        setTypeface(Typeface.DEFAULT_BOLD)
+        setPadding(0, context.dp(8), 0, context.dp(4))
+    })
+
+    fun LinearLayout.header(text: String) = add(TextView(context).apply {
+        this.text = text.uppercase()
+        setTextColor(DIM)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
+        setTypeface(Typeface.DEFAULT_BOLD)
+        letterSpacing = 0.08f
+        setPadding(0, context.dp(24), 0, context.dp(8))
+    })
+
+    fun LinearLayout.note(text: String) = add(TextView(context).apply {
+        this.text = text
+        setTextColor(DIM)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+        setLineSpacing(context.dp(3).toFloat(), 1f)
+        setPadding(0, 0, 0, context.dp(8))
+    })
+
+    fun LinearLayout.rule() = add(View(context).apply {
+        setBackgroundColor(RULE)
+        layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
+    })
+
+    // ---- rows --------------------------------------------------------------
+
+    /**
+     * A row: what it is, what it does now, and either a state chip or a chevron
+     * on the right. The whole row is the touch target.
+     */
+    fun LinearLayout.row(
+        title: String,
+        subtitle: String? = null,
+        enabled: Boolean = true,
+        state: String? = null,
+        onClick: (() -> Unit)? = null
+    ): LinearLayout {
+        val box = LinearLayout(context).apply {
+            orientation = LinearLayout.HORIZONTAL
+            gravity = Gravity.CENTER_VERTICAL
+            minimumHeight = context.dp(56)
+            val v = context.dp(10)
+            setPadding(0, v, 0, v)
+            isClickable = onClick != null && enabled
+            if (isClickable) setOnClickListener { onClick?.invoke() }
+        }
+        val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
+        texts.addView(TextView(context).apply {
+            text = title
+            setTextColor(if (enabled) INK else DIM)
+            setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
+        })
+        if (!subtitle.isNullOrBlank()) {
+            texts.addView(TextView(context).apply {
+                text = subtitle
+                setTextColor(DIM)
+                setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
+                setPadding(0, context.dp(2), 0, 0)
+            })
+        }
+        box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
+
+        if (state != null) {
+            box.addView(chip(context, state).apply {
+                layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+                    marginStart = context.dp(8)
+                }
+            })
+        } else if (box.isClickable) {
+            box.addView(Glyph(context, Glyph.CHEVRON).apply {
+                layoutParams = LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply {
+                    marginStart = context.dp(8)
+                }
+            })
+        }
+        add(box)
+        rule()
+        return box
+    }
+
+    // ---- buttons -----------------------------------------------------------
+
+    /** The main action of a screen: filled, 56 dp, full width. */
+    fun LinearLayout.primaryButton(text: String, onClick: () -> Unit) =
+        add(flatButton(context, text, 56, Color.WHITE, INK, 17f).apply {
+            setOnClickListener { onClick() }
+        })
+
+    /** Every other action: outlined, 48 dp, full width. */
+    fun LinearLayout.button(text: String, onClick: () -> Unit) =
+        add(flatButton(context, text, 48, INK, Color.WHITE, 16f).apply {
+            setOnClickListener { onClick() }
+        })
+
+    /**
+     * A button with the platform look taken off: our own outline or fill, no
+     * elevation, and no ripple, which smears on an e-ink panel.
+     */
+    private fun flatButton(
+        c: Context,
+        text: String,
+        height: Int,
+        textColor: Int,
+        fill: Int,
+        size: Float
+    ): Button = Button(c).apply {
+        this.text = text
+        isAllCaps = false
+        gravity = Gravity.CENTER
+        setTextColor(textColor)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
+        setTypeface(Typeface.DEFAULT_BOLD)
+        background = outline(c, fill)
+        stateListAnimator = null
+        elevation = 0f
+        minWidth = 0
+        minimumWidth = 0
+        minHeight = 0
+        minimumHeight = 0
+        setPadding(c.dp(16), 0, c.dp(16), 0)
+        layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, c.dp(height)).apply {
+            topMargin = c.dp(8)
+            bottomMargin = c.dp(8)
+        }
+    }
+
+    // ---- drawing -----------------------------------------------------------
+
+    /** A black outline on a fill: the one shape this app is built from. */
+    fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable =
+        GradientDrawable().apply {
+            shape = GradientDrawable.RECTANGLE
+            setColor(fill)
+            cornerRadius = c.dp(radius).toFloat()
+            setStroke(c.px(1), INK)
+        }
+
+    /** A short state word, in an outlined box. */
+    fun chip(c: Context, text: String): TextView = TextView(c).apply {
+        this.text = text
+        setTextColor(INK)
+        setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
+        setTypeface(Typeface.DEFAULT_BOLD)
+        background = outline(c, radius = 10)
+        setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3))
+        maxLines = 1
+    }
+
+    /**
+     * The back arrow and the row chevron. They are drawn, not typed, because a
+     * firmware font can be without the characters.
+     */
+    private class Glyph(c: Context, private val kind: Int) : View(c) {
+
+        private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+            color = INK
+            style = Paint.Style.STROKE
+            strokeWidth = c.resources.displayMetrics.density * 2f
+            strokeCap = Paint.Cap.ROUND
+            strokeJoin = Paint.Join.ROUND
+        }
+        private val path = Path()
+
+        override fun onDraw(canvas: Canvas) {
+            val cx = width / 2f
+            val cy = height / 2f
+            path.reset()
+            if (kind == BACK) {
+                val r = minOf(width, height) * 0.24f
+                val h = r * 0.62f
+                path.moveTo(cx + r, cy)
+                path.lineTo(cx - r, cy)
+                path.moveTo(cx - r + h, cy - h)
+                path.lineTo(cx - r, cy)
+                path.lineTo(cx - r + h, cy + h)
+            } else {
+                val w = minOf(width, height) * 0.17f
+                val h = w * 1.7f
+                path.moveTo(cx - w, cy - h)
+                path.lineTo(cx + w, cy)
+                path.lineTo(cx - w, cy + h)
+            }
+            canvas.drawPath(path, paint)
+        }
+
+        companion object {
+            const val BACK = 0
+            const val CHEVRON = 1
+        }
+    }
+
+    /** Full width by default. A caller that wants otherwise sets the params first. */
+    private fun LinearLayout.add(v: View) {
+        if (v.layoutParams == null) {
+            v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+        }
+        addView(v)
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Versions.kt b/app/src/main/java/dev/equwal/inkupdate/Versions.kt
new file mode 100644
index 0000000..f61aa29
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Versions.kt
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+/**
+ * A version number, with no Android type in it, so the unit tests run on the JVM.
+ *
+ * The app compares two kinds of text: a git tag ("v0.0.15-alpha") and an
+ * installed versionName ("0.0.15-alpha-full"). Both must give the same version.
+ *
+ * The rules:
+ *
+ *  - A leading "v" or "V" is not part of the number.
+ *  - Up to four numeric parts, separated by full stops. Missing parts are 0.
+ *  - The stage is alpha, beta, rc, or none. None is the newest.
+ *  - Text after the stage, like "-full" or "-debug", says nothing. It is dropped.
+ *  - Text that has no number at the start is not a version. [parse] gives null.
+ *
+ * [parse] never throws.
+ */
+data class Version(
+    val parts: List<Int>,
+    val stage: Int
+) : Comparable<Version> {
+
+    override fun compareTo(other: Version): Int {
+        for (i in 0 until PARTS) {
+            val d = parts[i].compareTo(other.parts[i])
+            if (d != 0) return d
+        }
+        return stage.compareTo(other.stage)
+    }
+
+    override fun toString(): String =
+        parts.joinToString(".") + STAGE_NAMES[stage]
+
+    companion object {
+
+        /** How many numeric parts a version keeps. */
+        const val PARTS = 4
+
+        const val ALPHA = 0
+        const val BETA = 1
+        const val RC = 2
+
+        /** No stage word. A finished release is newer than its own rc. */
+        const val FINAL = 3
+
+        private val STAGE_NAMES = arrayOf("-alpha", "-beta", "-rc", "")
+
+        /** More than this many digits in one part is not a version number. */
+        private const val MAX_DIGITS = 9
+
+        fun parse(text: String?): Version? {
+            if (text == null) return null
+            var s = text.trim()
+            if (s.isEmpty()) return null
+            if (s[0] == 'v' || s[0] == 'V') s = s.substring(1)
+
+            val parts = ArrayList<Int>(PARTS)
+            var i = 0
+            while (i < s.length && parts.size < PARTS) {
+                var digits = 0
+                var value = 0
+                while (i < s.length && s[i] in '0'..'9') {
+                    if (digits >= MAX_DIGITS) return null
+                    value = value * 10 + (s[i] - '0')
+                    digits++
+                    i++
+                }
+                if (digits == 0) return null
+                parts.add(value)
+                if (i < s.length && s[i] == '.') i++ else break
+            }
+            if (parts.isEmpty()) return null
+            while (parts.size < PARTS) parts.add(0)
+
+            return Version(parts, stageOf(s.substring(i)))
+        }
+
+        /** The stage word in the text that follows the numbers. */
+        private fun stageOf(rest: String): Int {
+            val r = rest.lowercase()
+            return when {
+                r.contains("alpha") -> ALPHA
+                r.contains("beta") -> BETA
+                r.contains("rc") -> RC
+                else -> FINAL
+            }
+        }
+
+        /** The text to show for a tag: the same tag without the leading "v". */
+        fun display(tag: String): String {
+            val s = tag.trim()
+            return if (s.isNotEmpty() && (s[0] == 'v' || s[0] == 'V')) s.substring(1) else s
+        }
+    }
+}
diff --git a/app/src/main/java/dev/equwal/inkupdate/Watched.kt b/app/src/main/java/dev/equwal/inkupdate/Watched.kt
new file mode 100644
index 0000000..d55132d
--- /dev/null
+++ b/app/src/main/java/dev/equwal/inkupdate/Watched.kt
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+/**
+ * One app that Ink Update watches.
+ *
+ * @param title what the screen calls the app.
+ * @param pkg the package name on the device.
+ * @param fdroidId the id on F-Droid, or null when the app is not there.
+ * @param repo the GitHub repository, as "owner/name".
+ * @param preferAsset a word in the name of the APK to take first, when a
+ *        release holds more than one APK. Null takes the first APK.
+ */
+data class Watched(
+    val title: String,
+    val pkg: String,
+    val fdroidId: String?,
+    val repo: String,
+    val preferAsset: String? = null
+) {
+    /** The page that F-Droid shows for the app. The F-Droid client opens it. */
+    fun fdroidPage(): String? = fdroidId?.let { "https://f-droid.org/packages/$it/" }
+
+    /** The F-Droid answer about the app. */
+    fun fdroidApi(): String? = fdroidId?.let { "https://f-droid.org/api/v1/packages/$it" }
+
+    /** The last ten releases of the repository. */
+    fun githubApi(): String = "https://api.github.com/repos/$repo/releases?per_page=10"
+
+    /** The page in the Play store. The Play client opens it. */
+    fun playPage(): String = "market://details?id=$pkg"
+}
+
+/**
+ * The list of watched apps. To watch one more app, add a line.
+ *
+ * Only the apps that are installed are shown and checked.
+ */
+object Watch {
+
+    val ALL: List<Watched> = listOf(
+        Watched(
+            title = "Rebind",
+            pkg = "dev.equwal.assistkey",
+            fdroidId = null,
+            repo = "equwal/rebind",
+            preferAsset = "full"
+        ),
+        Watched(
+            title = "Ink Recents",
+            pkg = "dev.equwal.inkrecents",
+            fdroidId = "dev.equwal.inkrecents",
+            repo = "equwal/ink-recents"
+        ),
+        Watched(
+            title = "Ink Dim",
+            pkg = "dev.equwal.inkdim",
+            fdroidId = "dev.equwal.inkdim",
+            repo = "equwal/ink-dim"
+        ),
+        Watched(
+            title = "Ink Update",
+            pkg = "dev.equwal.inkupdate",
+            fdroidId = "dev.equwal.inkupdate",
+            repo = "equwal/ink-update"
+        )
+    )
+}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..06e989f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- A circle arrow: check again. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:strokeColor="#111111"
+        android:strokeWidth="6"
+        android:strokeLineCap="round"
+        android:pathData="M80,54 A26,26 0 1 1 67,31.5" />
+    <path
+        android:strokeColor="#111111"
+        android:strokeWidth="6"
+        android:strokeLineCap="round"
+        android:strokeLineJoin="round"
+        android:pathData="M57.6,31.8 L67,31.5 L62.6,23.2" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_notify.xml b/app/src/main/res/drawable/ic_notify.xml
new file mode 100644
index 0000000..15fc59f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notify.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The notification icon. Android wants white on nothing, and it tints it. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24"
+    android:tint="#FFFFFFFF">
+    <path
+        android:strokeColor="#FFFFFFFF"
+        android:strokeWidth="1.8"
+        android:strokeLineCap="round"
+        android:pathData="M18,12 A6,6 0 1 1 15,6.8" />
+    <path
+        android:strokeColor="#FFFFFFFF"
+        android:strokeWidth="1.8"
+        android:strokeLineCap="round"
+        android:strokeLineJoin="round"
+        android:pathData="M11.4,7 L15,6.8 L13.4,3.6" />
+</vector>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..5c84730
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..5c84730
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f42ada6
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_background">#FFFFFF</color>
+</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..90311ad
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">Ink Update</string>
+    <string name="channel_updates">Updates</string>
+
+    <!-- The screen -->
+    <string name="none_installed">No watched app is on this device.</string>
+    <string name="check_now">Check now</string>
+    <string name="checking">Checking</string>
+    <string name="daily">Check once a day</string>
+    <string name="on">On</string>
+    <string name="off">Off</string>
+    <string name="notifications">Notifications</string>
+
+    <!-- The state of one app -->
+    <string name="up_to_date">Up to date</string>
+    <string name="update_to">Update: %1$s</string>
+    <string name="play_updates">Play updates this app</string>
+    <string name="no_answer">Could not check</string>
+
+    <!-- The time of the last check -->
+    <string name="never_checked">Not checked yet.</string>
+    <string name="checked">Checked %1$s.</string>
+    <string name="could_not_check">Could not check %1$s.</string>
+
+    <!-- About -->
+    <string name="about">About</string>
+    <string name="version">Version</string>
+    <string name="licence">Licence</string>
+    <string name="source_code">Source code</string>
+    <string name="coffee">Buy me a coffee</string>
+</resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..b7fc10a
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!--
+        E-ink: light, high contrast, no animation anywhere. No action bar:
+        the screen draws its own title. From Android 15 an app that targets
+        SDK 35 or later is laid out edge to edge, so the screen pads itself
+        for the system bars.
+    -->
+    <style name="Theme.App" parent="@android:style/Theme.DeviceDefault.Light.NoActionBar">
+        <item name="android:windowAnimationStyle">@null</item>
+        <item name="android:windowLightStatusBar">true</item>
+        <item name="android:windowLightNavigationBar">true</item>
+        <item name="android:statusBarColor">@android:color/white</item>
+        <item name="android:navigationBarColor">@android:color/white</item>
+    </style>
+</resources>
diff --git a/app/src/test/java/dev/equwal/inkupdate/DecideTest.kt b/app/src/test/java/dev/equwal/inkupdate/DecideTest.kt
new file mode 100644
index 0000000..613ea39
--- /dev/null
+++ b/app/src/test/java/dev/equwal/inkupdate/DecideTest.kt
@@ -0,0 +1,159 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** The order F-Droid, Google Play, GitHub. */
+class DecideTest {
+
+    private val fdroidPage = "https://f-droid.org/packages/dev.equwal.inkdim/"
+    private val ghPage = "https://github.com/equwal/ink-dim/releases/tag/v0.1.1"
+    private val apk = "https://github.com/equwal/ink-dim/releases/download/v0.1.1/ink-dim-0.1.1.apk"
+
+    private fun fdroidNewer() = FdroidAnswer.Found(2L, "0.1.1", fdroidPage)
+    private fun fdroidSame() = FdroidAnswer.Found(1L, "0.1.0", fdroidPage)
+    private fun githubNewer() = GithubAnswer.Found("v0.1.1", ghPage, apk)
+    private fun githubSame() = GithubAnswer.Found("v0.1.0", ghPage, apk)
+
+    @Test
+    fun fdroidWinsOverGithub() {
+        val r = Decide.source(null, fdroidNewer(), githubNewer(), 1L, "0.1.0")
+        assertTrue(r is Result.Update)
+        r as Result.Update
+        assertEquals(Decide.FDROID, r.source)
+        assertEquals("0.1.1", r.versionName)
+        assertEquals(fdroidPage, r.page)
+        // F-Droid installs the app by itself, so there is no APK link.
+        assertNull(r.apk)
+    }
+
+    @Test
+    fun fdroidNotThereFallsToGithub() {
+        val r = Decide.source(null, FdroidAnswer.NotThere, githubNewer(), 1L, "0.1.0")
+        assertTrue(r is Result.Update)
+        r as Result.Update
+        assertEquals(Decide.GITHUB, r.source)
+        assertEquals("0.1.1", r.versionName)
+        assertEquals(ghPage, r.page)
+        assertEquals(apk, r.apk)
+    }
+
+    @Test
+    fun fdroidNotThereAndPlayInstalledLeavesItToPlay() {
+        val r = Decide.source(
+            Decide.PLAY_PACKAGE, FdroidAnswer.NotThere, githubNewer(), 1L, "0.1.0"
+        )
+        assertEquals(Result.LeftToPlay, r)
+    }
+
+    /** The Play store does not beat a newer build on F-Droid. */
+    @Test
+    fun fdroidStillWinsOverPlay() {
+        val r = Decide.source(
+            Decide.PLAY_PACKAGE, fdroidNewer(), GithubAnswer.None, 1L, "0.1.0"
+        )
+        assertTrue(r is Result.Update)
+        assertEquals(Decide.FDROID, (r as Result.Update).source)
+    }
+
+    @Test
+    fun anotherInstallerDoesNotLeaveItToPlay() {
+        val r = Decide.source(
+            "com.android.packageinstaller", FdroidAnswer.NotThere, githubNewer(), 1L, "0.1.0"
+        )
+        assertEquals(Decide.GITHUB, (r as Result.Update).source)
+    }
+
+    @Test
+    fun theSameVersionIsUpToDate() {
+        assertEquals(
+            Result.UpToDate,
+            Decide.source(null, FdroidAnswer.NotThere, githubSame(), 1L, "0.1.0")
+        )
+        assertEquals(
+            Result.UpToDate,
+            Decide.source(null, fdroidSame(), githubSame(), 1L, "0.1.0")
+        )
+    }
+
+    /** A tag that is older than what is installed is not an update. */
+    @Test
+    fun anOlderTagIsUpToDate() {
+        val r = Decide.source(
+            null, FdroidAnswer.NotThere,
+            GithubAnswer.Found("v0.0.9-alpha", ghPage, apk), 1L, "0.1.0"
+        )
+        assertEquals(Result.UpToDate, r)
+    }
+
+    /** Rebind: the tag has no "-full", the installed name has. */
+    @Test
+    fun rebindTagsAndNamesLineUp() {
+        val page = "https://github.com/equwal/rebind/releases/tag/v0.0.16-alpha"
+        val full = "https://github.com/equwal/rebind/releases/download/" +
+            "v0.0.16-alpha/rebind-0.0.16-alpha-full.apk"
+        val r = Decide.source(
+            null, FdroidAnswer.NotThere,
+            GithubAnswer.Found("v0.0.16-alpha", page, full), 15L, "0.0.15-alpha-full"
+        )
+        assertTrue(r is Result.Update)
+        r as Result.Update
+        assertEquals("0.0.16-alpha", r.versionName)
+        assertEquals(full, r.apk)
+
+        val same = Decide.source(
+            null, FdroidAnswer.NotThere,
+            GithubAnswer.Found("v0.0.15-alpha", page, full), 15L, "0.0.15-alpha-full"
+        )
+        assertEquals(Result.UpToDate, same)
+    }
+
+    @Test
+    fun nobodyAnsweredIsUnknown() {
+        val r = Decide.source(
+            null, FdroidAnswer.Failed("No answer"), GithubAnswer.Failed("No answer"),
+            1L, "0.1.0"
+        )
+        assertTrue(r is Result.Unknown)
+        assertEquals("No answer", (r as Result.Unknown).reason)
+    }
+
+    /** F-Droid answered, so a failed GitHub request does not hide the answer. */
+    @Test
+    fun fdroidAnsweredAndGithubFailed() {
+        val r = Decide.source(
+            null, fdroidSame(), GithubAnswer.Failed("Too many requests"), 1L, "0.1.0"
+        )
+        assertEquals(Result.UpToDate, r)
+    }
+
+    /** GitHub answered, so a failed F-Droid request does not hide the answer. */
+    @Test
+    fun githubAnsweredAndFdroidFailed() {
+        val r = Decide.source(
+            null, FdroidAnswer.Failed("No answer"), githubNewer(), 1L, "0.1.0"
+        )
+        assertEquals(Decide.GITHUB, (r as Result.Update).source)
+    }
+
+    /** Neither place has the app. There is nothing newer to get. */
+    @Test
+    fun nothingAnywhereIsUpToDate() {
+        assertEquals(
+            Result.UpToDate,
+            Decide.source(null, FdroidAnswer.NotThere, GithubAnswer.None, 1L, "0.1.0")
+        )
+    }
+
+    @Test
+    fun aVersionNameThatMakesNoSenseIsUnknown() {
+        val r = Decide.source(
+            null, FdroidAnswer.NotThere, githubNewer(), 1L, "nightly"
+        )
+        assertTrue(r is Result.Unknown)
+    }
+}
diff --git a/app/src/test/java/dev/equwal/inkupdate/JsonTest.kt b/app/src/test/java/dev/equwal/inkupdate/JsonTest.kt
new file mode 100644
index 0000000..facb6fd
--- /dev/null
+++ b/app/src/test/java/dev/equwal/inkupdate/JsonTest.kt
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The two answers, as the hosts really send them.
+ *
+ * The F-Droid answers come from https://f-droid.org/api/v1/packages/.
+ * The GitHub answer keeps the shape of
+ * https://api.github.com/repos/equwal/rebind/releases?per_page=10.
+ */
+class JsonTest {
+
+    private val page = "https://f-droid.org/packages/org.fdroid.fdroid/"
+
+    // ---- F-Droid -----------------------------------------------------------
+
+    /** The real answer for org.fdroid.fdroid. */
+    private val fdroidReal = """
+        {"packageName":"org.fdroid.fdroid","suggestedVersionCode":1023052,
+        "packages":[{"versionName":"2.0-rc1","versionCode":2000041},
+        {"versionName":"2.0-rc0","versionCode":2000040},
+        {"versionName":"2.0-alpha11","versionCode":2000011},
+        {"versionName":"1.23.2","versionCode":1023052}]}
+    """.trimIndent()
+
+    @Test
+    fun readsARealFdroidAnswer() {
+        val a = Decide.parseFdroid(fdroidReal, page)
+        assertTrue(a is FdroidAnswer.Found)
+        a as FdroidAnswer.Found
+        // The suggested build, not the newest one. F-Droid suggests 1.23.2.
+        assertEquals(1023052L, a.versionCode)
+        assertEquals("1.23.2", a.versionName)
+        assertEquals(page, a.page)
+    }
+
+    /** All four watched apps get this answer today. It is not a failure. */
+    @Test
+    fun notFoundMeansNotThere() {
+        assertEquals(
+            FdroidAnswer.NotThere,
+            Decide.parseFdroid("""{"error":"NOT_FOUND"}""", page)
+        )
+    }
+
+    @Test
+    fun aBrokenFdroidAnswerFails() {
+        assertTrue(Decide.parseFdroid("not json", page) is FdroidAnswer.Failed)
+        assertTrue(Decide.parseFdroid("", page) is FdroidAnswer.Failed)
+        assertTrue(Decide.parseFdroid(null, page) is FdroidAnswer.Failed)
+        assertTrue(Decide.parseFdroid("""{"packageName":"a"}""", page) is FdroidAnswer.Failed)
+        assertTrue(Decide.parseFdroid("[1,2,3]", page) is FdroidAnswer.Failed)
+    }
+
+    // ---- GitHub ------------------------------------------------------------
+
+    /**
+     * A draft first, then two pre-releases. Every release of these
+     * repositories is a pre-release, so /releases/latest would give nothing.
+     */
+    private val githubReal = """
+        [
+          {"id":1,"tag_name":"v0.0.17-alpha","name":"Rebind 0.0.17-alpha",
+           "draft":true,"prerelease":true,
+           "html_url":"https://github.com/equwal/rebind/releases/tag/untagged-1",
+           "assets":[]},
+          {"id":2,"tag_name":"v0.0.15-alpha","name":"Rebind 0.0.15-alpha",
+           "draft":false,"prerelease":true,
+           "html_url":"https://github.com/equwal/rebind/releases/tag/v0.0.15-alpha",
+           "published_at":"2026-09-20T18:04:11Z",
+           "assets":[
+             {"name":"rebind-0.0.15-alpha-play.aab",
+              "browser_download_url":"https://github.com/equwal/rebind/releases/download/v0.0.15-alpha/rebind-0.0.15-alpha-play.aab"},
+             {"name":"rebind-0.0.15-alpha-play.apk",
+              "browser_download_url":"https://github.com/equwal/rebind/releases/download/v0.0.15-alpha/rebind-0.0.15-alpha-play.apk"},
+             {"name":"rebind-0.0.15-alpha-full.apk",
+              "browser_download_url":"https://github.com/equwal/rebind/releases/download/v0.0.15-alpha/rebind-0.0.15-alpha-full.apk"}
+           ]},
+          {"id":3,"tag_name":"v0.0.14-alpha","draft":false,"prerelease":true,
+           "html_url":"https://github.com/equwal/rebind/releases/tag/v0.0.14-alpha",
+           "assets":[]}
+        ]
+    """.trimIndent()
+
+    @Test
+    fun skipsTheDraftAndTakesThePreRelease() {
+        val a = Decide.parseGithub(githubReal, "full")
+        assertTrue(a is GithubAnswer.Found)
+        a as GithubAnswer.Found
+        assertEquals("v0.0.15-alpha", a.tag)
+        assertEquals("https://github.com/equwal/rebind/releases/tag/v0.0.15-alpha", a.page)
+        assertEquals(
+            "https://github.com/equwal/rebind/releases/download/" +
+                "v0.0.15-alpha/rebind-0.0.15-alpha-full.apk",
+            a.apk
+        )
+    }
+
+    /** Without a wanted word, the first APK wins. The .aab is not an APK. */
+    @Test
+    fun takesTheFirstApkWhenNothingIsPreferred() {
+        val a = Decide.parseGithub(githubReal) as GithubAnswer.Found
+        assertEquals(
+            "https://github.com/equwal/rebind/releases/download/" +
+                "v0.0.15-alpha/rebind-0.0.15-alpha-play.apk",
+            a.apk
+        )
+    }
+
+    @Test
+    fun aReleaseWithNoApkGivesNoLink() {
+        val one = """
+            [{"tag_name":"v0.1.0","draft":false,"prerelease":false,
+              "html_url":"https://github.com/equwal/ink-dim/releases/tag/v0.1.0",
+              "assets":[{"name":"notes.txt",
+                "browser_download_url":"https://example.invalid/notes.txt"}]}]
+        """.trimIndent()
+        val a = Decide.parseGithub(one, "full") as GithubAnswer.Found
+        assertEquals("v0.1.0", a.tag)
+        assertNull(a.apk)
+    }
+
+    @Test
+    fun anEmptyArrayMeansNoRelease() {
+        assertEquals(GithubAnswer.None, Decide.parseGithub("[]"))
+    }
+
+    @Test
+    fun onlyDraftsMeanNoRelease() {
+        val drafts = """[{"tag_name":"v9.9.9","draft":true,"assets":[]}]"""
+        assertEquals(GithubAnswer.None, Decide.parseGithub(drafts))
+    }
+
+    @Test
+    fun aBrokenGithubAnswerFails() {
+        assertTrue(Decide.parseGithub("not json") is GithubAnswer.Failed)
+        assertTrue(Decide.parseGithub("") is GithubAnswer.Failed)
+        assertTrue(Decide.parseGithub(null) is GithubAnswer.Failed)
+        // The rate limit answer is an object, not an array.
+        assertTrue(
+            Decide.parseGithub("""{"message":"API rate limit exceeded"}""")
+                is GithubAnswer.Failed
+        )
+        assertTrue(Decide.parseGithub("""[{"draft":false}]""") is GithubAnswer.Failed)
+    }
+
+    // ---- the two together --------------------------------------------------
+
+    /** What Rebind gets today: not on F-Droid, a newer pre-release on GitHub. */
+    @Test
+    fun rebindToday() {
+        val f = Decide.parseFdroid("""{"error":"NOT_FOUND"}""", page)
+        val g = Decide.parseGithub(githubReal, "full")
+        val r = Decide.source(null, f, g, 14L, "0.0.14-alpha-full")
+        assertTrue(r is Result.Update)
+        r as Result.Update
+        assertEquals(Decide.GITHUB, r.source)
+        assertEquals("0.0.15-alpha", r.versionName)
+        assertTrue(r.apk!!.endsWith("-full.apk"))
+    }
+}
diff --git a/app/src/test/java/dev/equwal/inkupdate/VersionTest.kt b/app/src/test/java/dev/equwal/inkupdate/VersionTest.kt
new file mode 100644
index 0000000..5da8706
--- /dev/null
+++ b/app/src/test/java/dev/equwal/inkupdate/VersionTest.kt
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (c) 2026 equwal
+package dev.equwal.inkupdate
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Random
+
+/** [Version.parse] and the order of two versions. */
+class VersionTest {
+
+    private fun v(text: String): Version {
+        val p = Version.parse(text)
+        assertNotNull("parse failed: " + text, p)
+        return p!!
+    }
+
+    @Test
+    fun readsTheParts() {
+        assertEquals(listOf(0, 0, 15, 0), v("v0.0.15-alpha").parts)
+        assertEquals(Version.ALPHA, v("v0.0.15-alpha").stage)
+        assertEquals(listOf(3, 7, 0, 0), v("V3.7").parts)
+        assertEquals(Version.FINAL, v("V3.7").stage)
+        assertEquals(listOf(5, 3, 0, 0), v("v5.3.0").parts)
+        assertEquals(Version.BETA, v("0.1.0-beta").stage)
+        assertEquals(Version.RC, v("2.0-rc1").stage)
+        assertEquals(listOf(1, 2, 3, 4), v("1.2.3.4").parts)
+    }
+
+    /** A tag and an installed versionName must give the same version. */
+    @Test
+    fun theTagEqualsTheInstalledName() {
+        assertEquals(v("v0.0.15-alpha"), v("0.0.15-alpha-full"))
+        assertEquals(v("v0.1.0"), v("0.1.0-debug"))
+        assertEquals(v("0.1.0"), v("0.1.0.0"))
+    }
+
+    @Test
+    fun ordersByNumberThenStage() {
+        assertTrue(v("0.1.0-beta") > v("0.0.15-alpha"))
+        assertTrue(v("0.1.0") > v("0.1.0-beta"))
+        assertTrue(v("0.1.0-beta") > v("0.1.0-alpha"))
+        assertTrue(v("0.1.0") > v("0.1.0-rc1"))
+        assertTrue(v("v5.3.0") > v("v5.2.11"))
+        assertTrue(v("3.7") < v("3.10"))
+        assertTrue(v("0.0.16-alpha") > v("0.0.15-alpha-full"))
+        assertEquals(0, v("1.2.3").compareTo(v("v1.2.3")))
+    }
+
+    @Test
+    fun junkIsNotAVersion() {
+        for (s in listOf("", "   ", "v", "alpha", "-1.0", "latest", "v.1.2", "x1.2")) {
+            assertNull("must be null: " + s, Version.parse(s))
+        }
+        assertNull(Version.parse(null))
+    }
+
+    /** A part of more than nine digits is not a version number. */
+    @Test
+    fun aVeryLongNumberIsNotAVersion() {
+        assertNull(Version.parse("12345678901234567890.1"))
+    }
+
+    @Test
+    fun parseNeverThrows() {
+        val r = Random(20260921L)
+        val letters = "0123456789.-vV abcRC+/\\\"{}é中"
+        for (i in 0 until 5000) {
+            val n = r.nextInt(12)
+            val sb = StringBuilder()
+            for (j in 0 until n) sb.append(letters[r.nextInt(letters.length)])
+            Version.parse(sb.toString())
+        }
+    }
+
+    // ---- properties --------------------------------------------------------
+
+    private fun random(r: Random): Version = Version(
+        List(Version.PARTS) { r.nextInt(12) },
+        r.nextInt(4)
+    )
+
+    private fun sign(n: Int): Int = if (n < 0) -1 else if (n > 0) 1 else 0
+
+    @Test
+    fun compareIsAntisymmetric() {
+        val r = Random(4242L)
+        for (i in 0 until 20000) {
+            val a = random(r)
+            val b = random(r)
+            assertEquals(
+                a.toString() + " vs " + b,
+                sign(a.compareTo(b)),
+                -sign(b.compareTo(a))
+            )
+        }
+    }
+
+    @Test
+    fun compareIsTransitive() {
+        val r = Random(99L)
+        for (i in 0 until 20000) {
+            val a = random(r)
+            val b = random(r)
+            val c = random(r)
+            if (a <= b && b <= c) {
+                assertTrue(a.toString() + " " + b + " " + c, a <= c)
+            }
+        }
+    }
+
+    /** Text made from a version parses back to the same version. */
+    @Test
+    fun theTextRoundTrips() {
+        val r = Random(7L)
+        for (i in 0 until 20000) {
+            val a = random(r)
+            assertEquals(a, Version.parse("v" + a))
+        }
+    }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..cdb90a1
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+// Versions are pinned so a clean clone builds the same APK.
+plugins {
+    id("com.android.application") version "8.13.2" apply false
+    id("org.jetbrains.kotlin.android") version "2.2.21" apply false
+}
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..c8d21c0
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/1.txt
@@ -0,0 +1,6 @@
+First release.
+
+Watches Rebind, Ink Recents, Ink Dim and Ink Update, and makes a notification
+when a new version is ready. It looks in F-Droid, then Google Play, then
+GitHub. A daily check lives through a restart. The intent action
+dev.equwal.inkupdate.CHECK starts a check.
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..0c90fe3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,28 @@
+Ink Update watches Rebind and its extensions. When a new version is ready, it
+makes a notification.
+
+The app does not install anything. It finds the new version and opens the page.
+You install it.
+
+Rebind has no internet permission. This app has it, so Rebind does not need it.
+You can remove Ink Update at any time, and Rebind keeps working.
+
+It watches Rebind, Ink Recents, Ink Dim and Ink Update. It shows only the ones
+that are on your device.
+
+It looks in three places, in this order. First F-Droid: if F-Droid has a newer
+build, the app opens the F-Droid page. Then Google Play: if Google Play
+installed the app, Google Play updates it, and Ink Update stops there. Then
+GitHub: the app reads the last ten releases and takes the newest one that is
+not a draft.
+
+Tap "Check now" to look. Turn on "Check once a day" for a daily check that
+lives through a restart.
+
+The app sends no identifier, no account, no advertisement and no analytics. It
+contacts two hosts, f-droid.org and api.github.com. Each one sees your IP
+address and the name of the package or the repository, and nothing else.
+
+Made for the Viwoods AiPaper readers.
+
+Free software, GPL-3.0-or-later.
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..3e608d1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+Tells you when a new version of Rebind or an Ink app is ready.
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..fb9aac0
--- /dev/null
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+Ink Update
diff --git a/fdroid/README.md b/fdroid/README.md
new file mode 100644
index 0000000..63904e0
--- /dev/null
+++ b/fdroid/README.md
@@ -0,0 +1,8 @@
+# How to submit Ink Update to F-Droid
+
+1. Tag the release `v0.1.0` and push the tag.
+2. Fork `fdroid/fdroiddata` on GitLab, then clone your fork.
+3. Copy `dev.equwal.inkupdate.yml` into `metadata/` of your clone.
+4. Run `fdroid readmeta` and `fdroid lint dev.equwal.inkupdate` to check it.
+5. Commit on a new branch and push it to your fork.
+6. Open a merge request against `fdroid/fdroiddata`.
diff --git a/fdroid/dev.equwal.inkupdate.yml b/fdroid/dev.equwal.inkupdate.yml
new file mode 100644
index 0000000..3c4d795
--- /dev/null
+++ b/fdroid/dev.equwal.inkupdate.yml
@@ -0,0 +1,27 @@
+Categories:
+  - System
+License: GPL-3.0-or-later
+AuthorName: equwal
+AuthorEmail: truex@equwal.com
+SourceCode: https://github.com/equwal/ink-update
+IssueTracker: https://github.com/equwal/ink-update/issues
+Changelog: https://github.com/equwal/ink-update/blob/main/CHANGELOG.md
+Donate: https://ko-fi.com/truex
+
+AutoName: Ink Update
+
+RepoType: git
+Repo: https://github.com/equwal/ink-update.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..3f6f2ce
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..eddabd2
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..fec70bd
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   gradlew start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh gradlew
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..a51ec4f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..89e62c5
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "InkUpdate"
+include(":app")