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 }