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 }