Recently Written · git

ink-update

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

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

Log | Files | Refs


app/src/main/java/dev/equwal/inkupdate/Decide.kt (7864 bytes)

1 // SPDX-License-Identifier: GPL-3.0-or-later
2 // Copyright (c) 2026 equwal
3 package dev.equwal.inkupdate
4 
5 import org.json.JSONArray
6 import org.json.JSONObject
7 
8 /** What F-Droid said about one app. */
9 sealed class FdroidAnswer {
10 
11     /** F-Droid does not have the app. */
12     object NotThere : FdroidAnswer()
13 
14     /** F-Droid has the app. [page] is the page for a person to open. */
15     data class Found(
16         val versionCode: Long,
17         val versionName: String,
18         val page: String
19     ) : FdroidAnswer()
20 
21     /** The request failed, or the answer made no sense. */
22     data class Failed(val reason: String) : FdroidAnswer()
23 }
24 
25 /** What GitHub said about one repository. */
26 sealed class GithubAnswer {
27 
28     /** The repository has no release that a person can get. */
29     object None : GithubAnswer()
30 
31     /**
32      * The newest release that is not a draft. Every release of these
33      * repositories is a pre-release, so the app must not use /releases/latest.
34      */
35     data class Found(
36         val tag: String,
37         val page: String,
38         val apk: String?
39     ) : GithubAnswer()
40 
41     /** The request failed, or the answer made no sense. */
42     data class Failed(val reason: String) : GithubAnswer()
43 }
44 
45 /** What the app knows about one watched app after a check. */
46 sealed class Result {
47 
48     /** The installed version is the newest one found. */
49     object UpToDate : Result()
50 
51     /** A newer version is there. [page] opens it, [apk] downloads it. */
52     data class Update(
53         val source: String,
54         val versionName: String,
55         val page: String,
56         val apk: String?
57     ) : Result()
58 
59     /** The Play store installed the app, so the Play store updates it. */
60     object LeftToPlay : Result()
61 
62     /** Nobody answered. */
63     data class Unknown(val reason: String) : Result()
64 }
65 
66 /**
67  * The order of the search, and the reading of the two answers.
68  *
69  * Nothing here touches Android, so the unit tests run on the JVM.
70  */
71 object Decide {
72 
73     const val FDROID = "F-Droid"
74     const val GITHUB = "GitHub"
75     const val PLAY = "Google Play"
76 
77     /** The package name of the Play store client. */
78     const val PLAY_PACKAGE = "com.android.vending"
79 
80     /** F-Droid and its common clients. They update the app themselves. */
81     val FDROID_CLIENTS = setOf("org.fdroid.fdroid", "org.fdroid.basic", "com.machiav3lli.fdroid")
82 
83     fun fromFdroidClient(installer: String?): Boolean = installer in FDROID_CLIENTS
84 
85     /**
86      * Where the update comes from. The order is F-Droid, Google Play, GitHub.
87      *
88      * 1. F-Droid holds a newer build: take it. The F-Droid client updates the
89      *    app by itself after the person opens the page once.
90      * 2. The Play store installed the app: the Play store updates it. Stop.
91      * 3. A F-Droid client installed the app: it updates the app. Stop. F-Droid
92      *    builds lag behind GitHub, and the app must not offer a sideload then.
93      * 4. GitHub holds a newer release: take it.
94      *
95      * @param installer the package that installed the app, from the system.
96      * @param byFdroid the installer is a F-Droid client. Checker also asks the
97      *    system whether the installer opens fdroidrepos:// links.
98      */
99     fun source(
100         installer: String?,
101         fdroid: FdroidAnswer,
102         github: GithubAnswer,
103         installedCode: Long,
104         installedName: String,
105         byFdroid: Boolean = fromFdroidClient(installer)
106     ): Result {
107         if (fdroid is FdroidAnswer.Found && fdroid.versionCode > installedCode) {
108             return Result.Update(FDROID, fdroid.versionName, fdroid.page, null)
109         }
110         if (installer == PLAY_PACKAGE) return Result.LeftToPlay
111         if (byFdroid) return Result.UpToDate
112 
113         if (github is GithubAnswer.Found) {
114             val theirs = Version.parse(github.tag)
115             val ours = Version.parse(installedName)
116             if (theirs == null || ours == null) {
117                 return Result.Unknown("Version not understood")
118             }
119             return if (theirs > ours) {
120                 Result.Update(GITHUB, Version.display(github.tag), github.page, github.apk)
121             } else {
122                 Result.UpToDate
123             }
124         }
125 
126         // GitHub gave no release. F-Droid answered, so the app is up to date.
127         if (fdroid is FdroidAnswer.Found) return Result.UpToDate
128         if (github is GithubAnswer.Failed) return Result.Unknown(github.reason)
129         if (fdroid is FdroidAnswer.Failed) return Result.Unknown(fdroid.reason)
130 
131         // Neither place has the app, so there is nothing newer to get.
132         return Result.UpToDate
133     }
134 
135     /**
136      * Reads the answer of https://f-droid.org/api/v1/packages/<id>.
137      *
138      * The answer {"error":"NOT_FOUND"} means that F-Droid does not have the
139      * app. That is not a failure.
140      *
141      * This function never throws.
142      */
143     fun parseFdroid(body: String?, page: String): FdroidAnswer {
144         if (body.isNullOrBlank()) return FdroidAnswer.Failed("Empty answer")
145         return try {
146             val o = JSONObject(body)
147             if (o.has("error")) return FdroidAnswer.NotThere
148             if (!o.has("suggestedVersionCode")) return FdroidAnswer.Failed("No version")
149             val code = o.getLong("suggestedVersionCode")
150             val packages = o.optJSONArray("packages") ?: JSONArray()
151             var name = ""
152             for (i in 0 until packages.length()) {
153                 val p = packages.optJSONObject(i) ?: continue
154                 if (p.optLong("versionCode", -1L) == code) {
155                     name = p.optString("versionName", "")
156                     break
157                 }
158             }
159             if (name.isEmpty() && packages.length() > 0) {
160                 name = packages.optJSONObject(0)?.optString("versionName", "") ?: ""
161             }
162             FdroidAnswer.Found(code, name, page)
163         } catch (e: Exception) {
164             FdroidAnswer.Failed("Answer not understood")
165         }
166     }
167 
168     /**
169      * Reads the answer of
170      * https://api.github.com/repos/<owner>/<repo>/releases?per_page=10.
171      *
172      * The first entry that is not a draft wins. A pre-release counts, because
173      * every release of these repositories is a pre-release.
174      *
175      * @param preferAsset a word in the name of the APK to take first.
176      *
177      * This function never throws.
178      */
179     fun parseGithub(body: String?, preferAsset: String? = null): GithubAnswer {
180         if (body.isNullOrBlank()) return GithubAnswer.Failed("Empty answer")
181         return try {
182             val list = JSONArray(body)
183             for (i in 0 until list.length()) {
184                 val r = list.optJSONObject(i) ?: continue
185                 if (r.optBoolean("draft", false)) continue
186                 val tag = r.optString("tag_name", "")
187                 if (tag.isEmpty()) return GithubAnswer.Failed("No tag")
188                 val page = r.optString("html_url", "")
189                 return GithubAnswer.Found(tag, page, apkOf(r, preferAsset))
190             }
191             GithubAnswer.None
192         } catch (e: Exception) {
193             GithubAnswer.Failed("Answer not understood")
194         }
195     }
196 
197     /** The APK of a release, or null. A name that holds [prefer] wins. */
198     private fun apkOf(release: JSONObject, prefer: String?): String? {
199         val assets = release.optJSONArray("assets") ?: return null
200         var first: String? = null
201         for (i in 0 until assets.length()) {
202             val a = assets.optJSONObject(i) ?: continue
203             val name = a.optString("name", "").lowercase()
204             if (!name.endsWith(".apk")) continue
205             val url = a.optString("browser_download_url", "")
206             if (url.isEmpty()) continue
207             if (prefer != null && name.contains(prefer.lowercase())) return url
208             if (first == null) first = url
209         }
210         return first
211     }
212 }