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 (7196 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     /**
81      * Where the update comes from. The order is F-Droid, Google Play, GitHub.
82      *
83      * 1. F-Droid holds a newer build: take it. The F-Droid client updates the
84      *    app by itself after the person opens the page once.
85      * 2. The Play store installed the app: the Play store updates it. Stop.
86      * 3. GitHub holds a newer release: take it.
87      *
88      * @param installer the package that installed the app, from the system.
89      */
90     fun source(
91         installer: String?,
92         fdroid: FdroidAnswer,
93         github: GithubAnswer,
94         installedCode: Long,
95         installedName: String
96     ): Result {
97         if (fdroid is FdroidAnswer.Found && fdroid.versionCode > installedCode) {
98             return Result.Update(FDROID, fdroid.versionName, fdroid.page, null)
99         }
100         if (installer == PLAY_PACKAGE) return Result.LeftToPlay
101 
102         if (github is GithubAnswer.Found) {
103             val theirs = Version.parse(github.tag)
104             val ours = Version.parse(installedName)
105             if (theirs == null || ours == null) {
106                 return Result.Unknown("Version not understood")
107             }
108             return if (theirs > ours) {
109                 Result.Update(GITHUB, Version.display(github.tag), github.page, github.apk)
110             } else {
111                 Result.UpToDate
112             }
113         }
114 
115         // GitHub gave no release. F-Droid answered, so the app is up to date.
116         if (fdroid is FdroidAnswer.Found) return Result.UpToDate
117         if (github is GithubAnswer.Failed) return Result.Unknown(github.reason)
118         if (fdroid is FdroidAnswer.Failed) return Result.Unknown(fdroid.reason)
119 
120         // Neither place has the app, so there is nothing newer to get.
121         return Result.UpToDate
122     }
123 
124     /**
125      * Reads the answer of https://f-droid.org/api/v1/packages/<id>.
126      *
127      * The answer {"error":"NOT_FOUND"} means that F-Droid does not have the
128      * app. That is not a failure.
129      *
130      * This function never throws.
131      */
132     fun parseFdroid(body: String?, page: String): FdroidAnswer {
133         if (body.isNullOrBlank()) return FdroidAnswer.Failed("Empty answer")
134         return try {
135             val o = JSONObject(body)
136             if (o.has("error")) return FdroidAnswer.NotThere
137             if (!o.has("suggestedVersionCode")) return FdroidAnswer.Failed("No version")
138             val code = o.getLong("suggestedVersionCode")
139             val packages = o.optJSONArray("packages") ?: JSONArray()
140             var name = ""
141             for (i in 0 until packages.length()) {
142                 val p = packages.optJSONObject(i) ?: continue
143                 if (p.optLong("versionCode", -1L) == code) {
144                     name = p.optString("versionName", "")
145                     break
146                 }
147             }
148             if (name.isEmpty() && packages.length() > 0) {
149                 name = packages.optJSONObject(0)?.optString("versionName", "") ?: ""
150             }
151             FdroidAnswer.Found(code, name, page)
152         } catch (e: Exception) {
153             FdroidAnswer.Failed("Answer not understood")
154         }
155     }
156 
157     /**
158      * Reads the answer of
159      * https://api.github.com/repos/<owner>/<repo>/releases?per_page=10.
160      *
161      * The first entry that is not a draft wins. A pre-release counts, because
162      * every release of these repositories is a pre-release.
163      *
164      * @param preferAsset a word in the name of the APK to take first.
165      *
166      * This function never throws.
167      */
168     fun parseGithub(body: String?, preferAsset: String? = null): GithubAnswer {
169         if (body.isNullOrBlank()) return GithubAnswer.Failed("Empty answer")
170         return try {
171             val list = JSONArray(body)
172             for (i in 0 until list.length()) {
173                 val r = list.optJSONObject(i) ?: continue
174                 if (r.optBoolean("draft", false)) continue
175                 val tag = r.optString("tag_name", "")
176                 if (tag.isEmpty()) return GithubAnswer.Failed("No tag")
177                 val page = r.optString("html_url", "")
178                 return GithubAnswer.Found(tag, page, apkOf(r, preferAsset))
179             }
180             GithubAnswer.None
181         } catch (e: Exception) {
182             GithubAnswer.Failed("Answer not understood")
183         }
184     }
185 
186     /** The APK of a release, or null. A name that holds [prefer] wins. */
187     private fun apkOf(release: JSONObject, prefer: String?): String? {
188         val assets = release.optJSONArray("assets") ?: return null
189         var first: String? = null
190         for (i in 0 until assets.length()) {
191             val a = assets.optJSONObject(i) ?: continue
192             val name = a.optString("name", "").lowercase()
193             if (!name.endsWith(".apk")) continue
194             val url = a.optString("browser_download_url", "")
195             if (url.isEmpty()) continue
196             if (prefer != null && name.contains(prefer.lowercase())) return url
197             if (first == null) first = url
198         }
199         return first
200     }
201 }