app/src/main/java/dev/equwal/inkrecents/Apps.kt (6873 bytes)
1 // SPDX-License-Identifier: GPL-3.0-or-later 2 // Copyright (c) 2026 equwal 3 package dev.equwal.inkrecents 4 5 import android.app.usage.UsageEvents 6 import android.app.usage.UsageStatsManager 7 import android.content.ComponentName 8 import android.content.Context 9 import android.content.Intent 10 import android.content.pm.PackageManager 11 import android.os.Process 12 13 /** The apps on this device, as the recents list needs them. */ 14 object Apps { 15 16 data class App(val label: String, val pkg: String, val activity: String) { 17 val component: ComponentName get() = ComponentName(pkg, activity) 18 } 19 20 /** One entry of the recent-apps list. [taskId] is known only with shell access. */ 21 data class Recent(val app: App, val taskId: Int?) 22 23 /** Everything with a launcher icon, alphabetical, this app included. */ 24 fun all(c: Context): List<App> { 25 val pm = c.packageManager 26 val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) 27 return pm.queryIntentActivities(main, 0) 28 .map { App(it.loadLabel(pm).toString(), it.activityInfo.packageName, it.activityInfo.name) } 29 .distinctBy { it.pkg + "/" + it.activity } 30 .sortedBy { it.label.lowercase() } 31 } 32 33 fun launch(c: Context, app: App): Boolean = runCatching { 34 c.startActivity( 35 Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) 36 .setComponent(app.component) 37 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) 38 ) 39 true 40 }.getOrDefault(false) 41 42 // ---- the usage log ----------------------------------------------------- 43 44 fun hasUsageAccess(c: Context): Boolean { 45 val ops = c.getSystemService(android.app.AppOpsManager::class.java) ?: return false 46 return ops.unsafeCheckOpNoThrow( 47 android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), c.packageName 48 ) == android.app.AppOpsManager.MODE_ALLOWED 49 } 50 51 /** Newest first, from the usage log. It needs the user's usage-access grant. */ 52 fun recentFromUsage(c: Context, limit: Int = 12): List<App> { 53 val usm = c.getSystemService(UsageStatsManager::class.java) ?: return emptyList() 54 val now = System.currentTimeMillis() 55 val events = usm.queryEvents(now - 3L * 24 * 60 * 60 * 1000, now) 56 val last = LinkedHashMap<String, Long>() 57 val e = UsageEvents.Event() 58 while (events.hasNextEvent()) { 59 events.getNextEvent(e) 60 if (e.eventType == UsageEvents.Event.ACTIVITY_RESUMED) last[e.packageName] = e.timeStamp 61 } 62 return rank(c, open(last, closedAt(c)), limit) 63 } 64 65 /** 66 * The packages to show, newest first. An app that the user closed stays off 67 * the list until the user opens that app again. 68 */ 69 fun open(lastUsed: Map<String, Long>, closedAt: Map<String, Long>): List<String> = 70 lastUsed.entries 71 .filter { (pkg, at) -> at > (closedAt[pkg] ?: Long.MIN_VALUE) } 72 .sortedByDescending { it.value } 73 .map { it.key } 74 75 private const val PREFS = "inkrecents_closed" 76 77 private fun closedAt(c: Context): Map<String, Long> = 78 c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).all 79 .mapNotNull { (pkg, at) -> (at as? Long)?.let { pkg to it } }.toMap() 80 81 /** 82 * Closes [pkg] without shell access. Android ends the background processes 83 * of the app, and the app leaves the list of this screen. 84 */ 85 fun closeWithoutShell(c: Context, pkg: String) { 86 if (pkg == c.packageName) return 87 runCatching { c.getSystemService(android.app.ActivityManager::class.java)?.killBackgroundProcesses(pkg) } 88 c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() 89 .putLong(pkg, System.currentTimeMillis()).apply() 90 } 91 92 // ---- the real task list ------------------------------------------------ 93 94 /** 95 * The tasks in the output of `dumpsys activity recents`, newest first, as 96 * package to task id. 97 * 98 * A task line names its app as `A=<uid>:<package>` or, for some tasks, as 99 * `I=<package>/<class>`. Only a task of type `standard` counts: the others 100 * are the home screen, the recents screen and the dream. A package that has 101 * more than one task keeps the newest task id. 102 */ 103 fun parseRecents(dump: String): Map<String, Int> { 104 val task = Regex("""Recent #\d+: Task\{\S+ #(\d+) type=(\w+) (?:A=\d+:|I=)([^\s/}]+)""") 105 val ids = LinkedHashMap<String, Int>() 106 task.findAll(dump) 107 .filter { it.groupValues[2] == "standard" } 108 .forEach { m -> 109 // A number too large for an Int is not a task id. Skip that line. 110 m.groupValues[1].toIntOrNull()?.let { ids.putIfAbsent(m.groupValues[3], it) } 111 } 112 return ids 113 } 114 115 /** Newest first, from the real task list of the system. It needs shell access. */ 116 fun recentFromShell(c: Context, limit: Int = 12, done: (List<Recent>?) -> Unit) { 117 if (!Shell.ready) return done(null) 118 Shell.run("dumpsys activity recents") { r -> 119 if (!r.ok) return@run done(null) 120 val ids = parseRecents(r.output) 121 done(rank(c, ids.keys.toList(), limit).map { Recent(it, ids[it.pkg]) }) 122 } 123 } 124 125 /** 126 * The picture that the system keeps of a task. The files belong to `system`, 127 * so this works only where the shell is root, or can use `su`. It gives null 128 * in every other case. 129 */ 130 fun snapshot(taskId: Int, done: (android.graphics.Bitmap?) -> Unit) { 131 val dir = "/data/system_ce/0/snapshots/" 132 val pick = "f=$dir${taskId}_reduced.jpg; [ -e \$f ] || f=$dir$taskId.jpg; " 133 Shell.run(pick + "base64 -w 0 \$f 2>/dev/null || su 0 base64 -w 0 \$f 2>/dev/null") { r -> 134 val bytes = runCatching { 135 android.util.Base64.decode(r.output.trim(), android.util.Base64.DEFAULT) 136 }.getOrNull() 137 done(bytes?.takeIf { it.size > 100 }?.let { 138 android.graphics.BitmapFactory.decodeByteArray(it, 0, it.size) 139 }) 140 } 141 } 142 143 /** Keeps the apps that have a launcher icon, and drops this app and the home screens. */ 144 private fun rank(c: Context, pkgs: List<String>, limit: Int): List<App> { 145 val byPkg = all(c).associateBy { it.pkg } 146 val home = homePackages(c) 147 return pkgs.distinct() 148 .filter { it != c.packageName && it !in home } 149 .mapNotNull { byPkg[it] } 150 .take(limit) 151 } 152 153 private fun homePackages(c: Context): Set<String> = 154 c.packageManager.queryIntentActivities( 155 Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY 156 ).map { it.activityInfo.packageName }.toSet() 157 }