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")