app/src/main/java/dev/equwal/inkupdate/Checker.kt (5645 bytes)
1 // SPDX-License-Identifier: GPL-3.0-or-later 2 // Copyright (c) 2026 equwal 3 package dev.equwal.inkupdate 4 5 import android.content.Context 6 import android.content.Intent 7 import android.content.pm.PackageManager 8 import android.net.Uri 9 10 /** 11 * The check itself: what is installed, what the two hosts say, and what that 12 * means. 13 * 14 * Every function here makes network requests. Call them from a background 15 * thread only. 16 */ 17 object Checker { 18 19 private const val GITHUB_ACCEPT = "application/vnd.github+json" 20 21 /** A watched app that is on the device. */ 22 data class Installed( 23 val watched: Watched, 24 val versionName: String, 25 val versionCode: Long, 26 val installer: String?, 27 val byFdroid: Boolean = Decide.fromFdroidClient(installer) 28 ) 29 30 /** A row of the screen: an installed app and what the last check found. */ 31 data class Row(val app: Installed, val result: Result?) 32 33 /** The watched apps that are on the device, in the order of the list. */ 34 fun installed(context: Context): List<Installed> { 35 val pm = context.packageManager 36 val out = ArrayList<Installed>() 37 for (w in Watch.ALL) { 38 val info = try { 39 pm.getPackageInfo(w.pkg, 0) 40 } catch (e: PackageManager.NameNotFoundException) { 41 null 42 } catch (e: Exception) { 43 null 44 } ?: continue 45 val installer = installerOf(pm, w.pkg) 46 out.add( 47 Installed( 48 watched = w, 49 versionName = info.versionName ?: "", 50 versionCode = info.longVersionCode, 51 installer = installer, 52 byFdroid = fdroidClient(pm, installer) 53 ) 54 ) 55 } 56 return out 57 } 58 59 /** The rows to show, from what the store holds. This makes no request. */ 60 fun rows(context: Context, store: Store): List<Row> = 61 installed(context).map { Row(it, store.result(it.watched.pkg)) } 62 63 /** 64 * Checks every installed watched app, keeps the results, and tells the 65 * person about each new update. 66 * 67 * @return how many apps have an update. 68 */ 69 fun checkAll(context: Context, store: Store): Int { 70 val apps = installed(context) 71 var updates = 0 72 var ok = true 73 for (app in apps) { 74 val r = check(app) 75 if (r is Result.Unknown) ok = false 76 store.put(app.watched.pkg, r) 77 if (r is Result.Update) { 78 updates++ 79 // The version is marked only after the notification went out. 80 // A person who turns notifications on later still hears about 81 // the update at the next check. 82 if (store.notified(app.watched.pkg) != r.versionName && 83 Notify.update(context, app.watched, r) 84 ) { 85 store.setNotified(app.watched.pkg, r.versionName) 86 } 87 } 88 } 89 store.checked(System.currentTimeMillis(), ok) 90 return updates 91 } 92 93 /** The check of one app: F-Droid, then the Play store, then GitHub. */ 94 fun check(app: Installed): Result { 95 val w = app.watched 96 val fdroid = askFdroid(w) 97 val fdroidWins = fdroid is FdroidAnswer.Found && fdroid.versionCode > app.versionCode 98 99 // Two cases make the GitHub request waste: F-Droid already has a newer 100 // build, and the Play store updates the app by itself. 101 val github = 102 if (fdroidWins || app.installer == Decide.PLAY_PACKAGE || app.byFdroid) GithubAnswer.None 103 else askGithub(w) 104 105 return Decide.source( 106 app.installer, fdroid, github, app.versionCode, app.versionName, app.byFdroid 107 ) 108 } 109 110 private fun askFdroid(w: Watched): FdroidAnswer { 111 val url = w.fdroidApi() ?: return FdroidAnswer.NotThere 112 val page = w.fdroidPage() ?: return FdroidAnswer.NotThere 113 val a = Net.get(url) 114 return when { 115 a.code == 0 -> FdroidAnswer.Failed("No answer") 116 a.code == 404 -> FdroidAnswer.NotThere 117 a.code !in 200..299 -> FdroidAnswer.Failed("F-Droid said " + a.code) 118 else -> Decide.parseFdroid(a.body, page) 119 } 120 } 121 122 private fun askGithub(w: Watched): GithubAnswer { 123 val a = Net.get(w.githubApi(), GITHUB_ACCEPT) 124 return when { 125 a.code == 0 -> GithubAnswer.Failed("No answer") 126 a.code == 403 || a.code == 429 -> GithubAnswer.Failed("Too many requests") 127 a.code !in 200..299 -> GithubAnswer.Failed("GitHub said " + a.code) 128 else -> Decide.parseGithub(a.body, w.preferAsset) 129 } 130 } 131 132 /** 133 * True when the installer is a F-Droid client. There are many clients, and 134 * each one opens fdroidrepos:// links, so the system can tell. 135 */ 136 private fun fdroidClient(pm: PackageManager, installer: String?): Boolean { 137 if (installer == null) return false 138 if (Decide.fromFdroidClient(installer)) return true 139 val view = Intent(Intent.ACTION_VIEW, Uri.parse("fdroidrepos://f-droid.org/repo")) 140 .setPackage(installer) 141 return try { 142 pm.queryIntentActivities(view, 0).isNotEmpty() 143 } catch (e: Exception) { 144 false 145 } 146 } 147 148 /** The package that installed the app, as the system knows it. */ 149 private fun installerOf(pm: PackageManager, pkg: String): String? = try { 150 pm.getInstallSourceInfo(pkg).installingPackageName 151 } catch (e: Exception) { 152 null 153 } 154 }