Recently Written · git

ink-recents

Ink Recents: a recent-apps switcher drawn for e-ink readers. No animation, swipe up to close, swipe down to close the others.

git clone https://github.com/equwal/ink-recents

Log | Files | Refs


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 }