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/RecentsActivity.kt (15970 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.Activity
6 import android.content.Intent
7 import android.graphics.Color
8 import android.graphics.Typeface
9 import android.graphics.drawable.GradientDrawable
10 import android.provider.Settings
11 import android.util.TypedValue
12 import android.view.GestureDetector
13 import android.view.Gravity
14 import android.view.MotionEvent
15 import android.view.View
16 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
17 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
18 import android.view.WindowInsets
19 import android.widget.HorizontalScrollView
20 import android.widget.ImageView
21 import android.widget.LinearLayout
22 import android.widget.TextView
23 import dev.equwal.inkrecents.Ui.button
24 import dev.equwal.inkrecents.Ui.dp
25 import dev.equwal.inkrecents.Ui.note
26 import dev.equwal.inkrecents.Ui.title
27 import kotlin.math.abs
28 
29 /**
30  * Recent apps, as a row of cards.
31  *
32  * It works like the Android switcher: swipe sideways through the cards, tap a
33  * card to go to that app, swipe a card up to close that app. It is drawn for
34  * e-ink: black outlines on white, and the row jumps from card to card with no
35  * animation.
36  *
37  * Android does not tell an app what is running. With shell access the cards are
38  * the real tasks of the system, a card can close its task, and where the shell
39  * is root each card shows the picture that the system keeps of the task.
40  * Without shell access the order comes from the usage log, and a card shows the
41  * app icon.
42  */
43 class RecentsActivity : Activity() {
44 
45     private var row: LinearLayout? = null
46     private var scroller: HorizontalScrollView? = null
47     private var strip: LinearLayout? = null
48     private var heading: TextView? = null
49     private var closeOthers: TextView? = null
50     private var names: List<String> = emptyList()
51     private var shown: List<Apps.Recent> = emptyList()
52     private var cardStep = 0
53     private var index = 0
54 
55     /** Each time the screen opens, it starts at the newest app and shows the hint again. */
56     override fun onStart() {
57         super.onStart()
58         index = 0
59         hinted = false
60     }
61 
62     override fun onResume() {
63         super.onResume()
64         Shell.connect(this)
65         load()
66     }
67 
68     private fun load() {
69         Apps.recentFromShell(this) { fromShell ->
70             if (isFinishing) return@recentFromShell
71             when {
72                 fromShell != null -> show(fromShell)
73                 Apps.hasUsageAccess(this) ->
74                     show(Apps.recentFromUsage(this).map { Apps.Recent(it, null) })
75                 else -> askForAccess()
76             }
77         }
78     }
79 
80     private fun openSettings() {
81         startActivity(Intent(this, SettingsActivity::class.java))
82     }
83 
84     // ---- the cards ---------------------------------------------------------
85 
86     private fun show(recents: List<Apps.Recent>) {
87         val screenW = resources.displayMetrics.widthPixels
88         val screenH = resources.displayMetrics.heightPixels
89         val cardW = (screenW * 0.70f).toInt()
90         val cardH = (screenH * 0.56f).toInt()
91         val gap = dp(16)
92         cardStep = cardW + gap
93         names = recents.map { it.app.label }
94         shown = recents
95         index = index.coerceIn(0, maxOf(0, recents.size - 1))
96 
97         val root = LinearLayout(this).apply {
98             orientation = LinearLayout.VERTICAL
99             setBackgroundColor(Color.WHITE)
100             setOnApplyWindowInsetsListener { v, insets ->
101                 val bars = insets.getInsets(WindowInsets.Type.systemBars())
102                 v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
103                 insets
104             }
105         }
106 
107         // The heading names the card in the middle. A long press on it, or the
108         // small word beside it, opens the settings.
109         heading = TextView(this).apply {
110             text = if (recents.isEmpty()) "Nothing recent" else "Recent apps"
111             setTextColor(Color.BLACK)
112             setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
113             typeface = Typeface.DEFAULT_BOLD
114             setSingleLine()
115             setPadding(dp(20), dp(16), dp(8), dp(12))
116             setOnLongClickListener { openSettings(); true }
117         }
118         val bar = LinearLayout(this).apply {
119             orientation = LinearLayout.HORIZONTAL
120             gravity = Gravity.CENTER_VERTICAL
121         }
122         bar.addView(heading, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
123         bar.addView(TextView(this).apply {
124             text = "Settings"
125             setTextColor(Color.BLACK)
126             setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
127             setSingleLine()
128             setPadding(dp(10), dp(14), dp(20), dp(12))
129             setOnClickListener { openSettings() }
130         })
131         root.addView(bar)
132 
133         val cards = LinearLayout(this).apply {
134             orientation = LinearLayout.HORIZONTAL
135             // Side padding puts the first and the last card in the middle too.
136             val side = (screenW - cardW) / 2
137             setPadding(side, 0, side, 0)
138         }
139         recents.forEach { r ->
140             cards.addView(
141                 card(r),
142                 LinearLayout.LayoutParams(cardW, cardH).apply { marginEnd = gap }
143             )
144         }
145         row = cards
146 
147         val scroll = object : HorizontalScrollView(this) {
148             private val slop = android.view.ViewConfiguration.get(context).scaledTouchSlop
149             private var downX = 0f
150             private var downY = 0f
151 
152             /** A sideways drag belongs to the row. A tap or an upward swipe belongs to the card. */
153             override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
154                 when (ev.actionMasked) {
155                     MotionEvent.ACTION_DOWN -> { downX = ev.x; downY = ev.y }
156                     MotionEvent.ACTION_MOVE -> {
157                         val dx = abs(ev.x - downX)
158                         if (dx > slop && dx > abs(ev.y - downY)) return true
159                     }
160                 }
161                 return false
162             }
163 
164             /**
165              * One swipe, one card, in one step. The row does not follow the
166              * finger: e-ink shows every frame of a moving row as a smear.
167              */
168             override fun onTouchEvent(ev: MotionEvent): Boolean {
169                 if (ev.actionMasked == MotionEvent.ACTION_UP) {
170                     val dx = ev.x - downX
171                     if (abs(dx) > slop) show(index + if (dx < 0) 1 else -1)
172                 }
173                 return true
174             }
175         }.apply {
176             isHorizontalScrollBarEnabled = false
177             overScrollMode = View.OVER_SCROLL_NEVER
178             addView(cards, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
179         }
180         scroller = scroll
181         root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
182 
183         // Every recent app at once, as icons. The one in the middle is marked,
184         // and a tap on an icon brings its card to the middle.
185         val icons = LinearLayout(this).apply {
186             orientation = LinearLayout.HORIZONTAL
187             gravity = Gravity.CENTER_HORIZONTAL
188             setPadding(dp(12), dp(14), dp(12), dp(4))
189         }
190         recents.forEachIndexed { i, r ->
191             icons.addView(
192                 ImageView(this).apply {
193                     setImageDrawable(runCatching { packageManager.getApplicationIcon(r.app.pkg) }.getOrNull())
194                     contentDescription = r.app.label
195                     val p = dp(6)
196                     setPadding(p, p, p, p)
197                     setOnClickListener { show(i) }
198                 },
199                 LinearLayout.LayoutParams(dp(52), dp(52)).apply { marginEnd = dp(4) }
200             )
201         }
202         strip = icons
203         root.addView(
204             HorizontalScrollView(this).apply {
205                 isHorizontalScrollBarEnabled = false
206                 addView(icons, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT))
207             },
208             LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
209         )
210 
211         closeOthers = null
212         if (recents.isNotEmpty()) {
213             // Two tall buttons, one above the other with space between, so that
214             // a finger does not hit the wrong one.
215             fun bigButton(black: Boolean, onClick: () -> Unit) = TextView(this).apply {
216                 gravity = Gravity.CENTER
217                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
218                 typeface = Typeface.DEFAULT_BOLD
219                 setSingleLine()
220                 setTextColor(if (black) Color.WHITE else Color.BLACK)
221                 background = GradientDrawable().apply {
222                     setColor(if (black) Color.BLACK else Color.WHITE)
223                     setStroke(dp(2), Color.BLACK)
224                     cornerRadius = dp(8).toFloat()
225                 }
226                 setOnClickListener { onClick() }
227             }
228             fun place(v: View, top: Int) = root.addView(
229                 v,
230                 LinearLayout.LayoutParams(MATCH_PARENT, dp(60)).apply { setMargins(dp(20), top, dp(20), 0) }
231             )
232             if (recents.size > 1) {
233                 // The name is filled in by show(i): it is the card in the middle.
234                 closeOthers = bigButton(black = true) { close(recents.filterIndexed { i, _ -> i != index }) }
235                 place(closeOthers!!, dp(8))
236             }
237             val all = bigButton(black = false) { close(recents) }.apply { text = "Close all" }
238             place(all, dp(20))
239         }
240         setContentView(root)
241         scroll.post { show(index); hint() }
242     }
243 
244     /**
245      * Closes apps that you are done with. With shell access the task leaves the
246      * list of the system and its background processes end. Without it, Android
247      * ends the background processes and the app leaves the list of this screen.
248      */
249     private fun close(apps: List<Apps.Recent>) {
250         index = 0
251         val mine = apps.filter { it.app.pkg != packageName }
252         val (withTask, without) = mine.partition { it.taskId != null && Shell.ready }
253         without.forEach { Apps.closeWithoutShell(this, it.app.pkg) }
254         if (withTask.isEmpty()) return load()
255         Shell.runAll(withTask.flatMap { listOf("am stack remove " + it.taskId, "am kill " + it.app.pkg) }) { load() }
256     }
257 
258     private var hinted = false
259 
260     /**
261      * Shows once what a swipe does: the card in the middle jumps up and comes
262      * back, then the row jumps sideways and comes back. Four still frames, no
263      * glide, so that e-ink draws them clean.
264      */
265     private fun hint() {
266         if (hinted) return
267         hinted = true
268         val s = scroller ?: return
269         val card = row?.getChildAt(index) ?: return
270         val many = (row?.childCount ?: 0) > 1
271         s.postDelayed({ card.translationY = -dp(28).toFloat() }, 500)
272         s.postDelayed({ card.translationY = 0f }, 900)
273         if (many) {
274             val side = if (index == 0) dp(40) else -dp(40)
275             s.postDelayed({ s.scrollTo(index * cardStep + side, 0) }, 1300)
276             s.postDelayed({ s.scrollTo(index * cardStep, 0) }, 1700)
277         }
278     }
279 
280     /** Puts card [i] in the middle, in one step, and says which one it is. */
281     private fun show(i: Int) {
282         val s = scroller ?: return
283         val count = row?.childCount ?: 0
284         if (cardStep <= 0 || count == 0) return
285         index = i.coerceIn(0, count - 1)
286         s.scrollTo(index * cardStep, 0)
287         heading?.text = names.getOrElse(index) { "" } + "   " + (index + 1) + " of " + count
288         closeOthers?.text = "Close all but " + names.getOrElse(index) { "this one" }
289         strip?.let { icons ->
290             for (n in 0 until icons.childCount) {
291                 icons.getChildAt(n).background = if (n != index) null else GradientDrawable().apply {
292                     setStroke(dp(2), Color.BLACK)
293                     cornerRadius = dp(10).toFloat()
294                 }
295             }
296         }
297     }
298 
299     private fun card(r: Apps.Recent): View {
300         val box = LinearLayout(this).apply {
301             orientation = LinearLayout.VERTICAL
302             background = GradientDrawable().apply {
303                 setColor(Color.WHITE)
304                 setStroke(dp(2), Color.BLACK)
305                 cornerRadius = dp(14).toFloat()
306             }
307             val p = dp(3)
308             setPadding(p, p, p, p)
309             clipToOutline = true
310         }
311 
312         val icon = runCatching { packageManager.getApplicationIcon(r.app.pkg) }.getOrNull()
313         box.addView(LinearLayout(this).apply {
314             orientation = LinearLayout.HORIZONTAL
315             gravity = Gravity.CENTER_VERTICAL
316             setPadding(dp(12), dp(10), dp(12), dp(10))
317             addView(ImageView(context).apply { setImageDrawable(icon) }, LinearLayout.LayoutParams(dp(32), dp(32)))
318             addView(TextView(context).apply {
319                 text = r.app.label
320                 setTextColor(Color.BLACK)
321                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
322                 setSingleLine()
323                 setPadding(dp(10), 0, 0, 0)
324             })
325         })
326         box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1)))
327 
328         // The body: the app icon now, the task picture when and if it arrives.
329         val body = ImageView(this).apply {
330             setImageDrawable(icon)
331             scaleType = ImageView.ScaleType.CENTER_INSIDE
332             val p = dp(64)
333             setPadding(p, p, p, p)
334         }
335         box.addView(body, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
336         if (r.app.pkg != packageName) {
337             box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1)))
338             box.addView(TextView(this).apply {
339                 text = "↑ close this     ↓ close the others"
340                 setTextColor(Color.BLACK)
341                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
342                 gravity = Gravity.CENTER_HORIZONTAL
343                 setPadding(0, dp(8), 0, dp(8))
344             })
345         }
346         r.taskId?.let { id ->
347             Apps.snapshot(id) { picture ->
348                 if (picture != null && !isFinishing) {
349                     body.setPadding(0, 0, 0, 0)
350                     body.scaleType = ImageView.ScaleType.FIT_START
351                     body.setImageBitmap(picture)
352                 }
353             }
354         }
355 
356         val gestures = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
357             override fun onDown(e: MotionEvent): Boolean = true
358 
359             override fun onSingleTapUp(e: MotionEvent): Boolean {
360                 Apps.launch(this@RecentsActivity, r.app)
361                 finish()
362                 return true
363             }
364 
365             override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean {
366                 val up = e1 != null && e1.y - e2.y > box.height / 5 && abs(vy) > abs(vx)
367                 val down = e1 != null && e2.y - e1.y > box.height / 5 && abs(vy) > abs(vx)
368                 when {
369                     up -> close(listOf(r))
370                     down -> close(shown.filter { it !== r })
371                     else -> return false
372                 }
373                 return true
374             }
375         })
376         box.setOnTouchListener { v, e ->
377             if (e.actionMasked == MotionEvent.ACTION_UP) v.performClick()
378             gestures.onTouchEvent(e)
379         }
380         return box
381     }
382 
383     // ---- no access yet -----------------------------------------------------
384 
385     private fun askForAccess() {
386         val col = Ui.page(this)
387         col.title("Recent apps")
388         col.note("Usage access puts your apps in order of last use.")
389         col.button("Give usage access") {
390             runCatching { startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
391         }
392         col.button("Settings") { openSettings() }
393     }
394 }