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 }