7 Commits
Author SHA1 Message Date
TMC 0959f36587 Release 0.4.0 2026-11-12 09:00:30 +01:00
TMCandClaude Fable 5.2 8aba905d9c Thumb-friendly drawer: compact list, Niagara-style alphabet, dark backdrop; QR code on About
Requests from the owner on 12 November 2026, after using 0.3.1 on the Pixel 9a, with a
photo of Niagara Launcher's alphabet as the reference.

Drawer list
- Rows are 50dp with 34dp icons and 15sp names (were 64dp, 46dp, 17sp); letter headers are
  34dp. A row grows with a large system font so a notification preview still fits.

Alphabet
- Letters sit 20dp apart at most (was 30dp). The strip keeps a stretch of edge as tall as
  26 letters, low on the edge with its end 40dp above the navigation bar, and centres the
  letters that exist in it. It used to spread over the whole edge, which put "A" out of a
  thumb's reach.
- The wave follows Niagara's: letters keep their size, the wave is wide (104dp) so about
  five letters on each side of the finger curve around the thumb, and the selected letter
  is shown large in a light bubble just beyond the wave's peak. This replaces the enlarged
  letter on a dark spot from 0.3.1, which collided with its neighbours at this spacing.
- The list dims a little during any scrub, more where the wave lies over it.

Backdrop
- The drawer's scrim is black at 78% in every theme (AllAppsState). Lawnchair's grey at 40%
  left a light grey behind white names over a light wallpaper. LawnchairScrimView judges the
  status bar icons by the scrim's own colour again, since that colour is now always dark.

Scrub from the home screen (PRD AD-10)
- On by default, the owner's decision; this closes the PRD's open question.
- It opens the drawer in 120 ms instead of the state manager's 600 ms, like swipe up and hold.
- A touch that lifts within 200 ms was a flick and opens the plain list.

Fix: undoing a scrub that was not meant
- 0.3.1 put the list back to "where it was", read from the list's rows. A closed drawer has
  been told to return to the top but lays its rows out only when it opens, so the rows
  still reported the place the drawer was last left at, and a quick swipe reopened it
  there. The list now goes where a plainly opened drawer stands.

About
- A QR code for the release page, generated on the phone with ZXing core (new dependency),
  black on white in every theme, with the address under it.

Checked on the Android 17 emulator with timed touches, a thumb-sized shape over the
screenshots, a 1.6 font scale, and by decoding the QR code from a screenshot.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 09:00:22 +01:00
TMCandClaude Fable 5.2 60d00afcfd release.ps1: keep Gradle's warnings from stopping a piped run
Gradle prints compiler warnings on its error stream. When the script's output was piped or
sent to a file, PowerShell 5.1 took the first such line for an error and stopped the build
at step 2 (seen on the first 0.3.1 attempt). The streams are now merged inside cmd, and
only the exit code decides whether the build failed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 08:07:30 +01:00
TMC 74becdd355 Release 0.3.1 2026-11-12 08:02:33 +01:00
TMCandClaude Fable 5.2 4771d2928a Start the alphabet scrub the moment a hold is noticed; keep the selected letter clear of the thumb
Two reports from first use on the Pixel 9a (PRD AD-5, AD-6, AD-17).

Swipe up and hold was slow to hand over to the alphabet:
- After the hold was noticed, the drawer was sent to its open state through the state
  manager, which takes 600 ms, and the alphabet took the finger only after that, and then
  only on the next touch movement. A finger held perfectly still never started it.
- Now the alphabet takes the finger's position in the same call that notices the hold, and
  the drawer finishes opening under it by playing the swipe's own animation to its end in
  120 ms.
- A still finger counts as holding after 150 ms instead of 300 (MotionPauseDetector gains
  setForcePauseTimeout, reset by clear()), and the swipe has to travel 32dp instead of 72dp
  before a hold counts.
- Because the list now jumps at once, a finger that lifts within 200 ms of the hold being
  noticed is treated as an ordinary swipe that slowed down at its end: the scrub is undone
  and the list stays where it was.

The selected letter sat under the thumb:
- The wave's peak was 52dp beyond the finger, about 8 mm, and a thumb hides about 11 mm to
  each side of where it touches. It is now 104dp, and at least 96dp out from the edge.
- The peak may travel to within 36dp of the far edge (was 70% of the width), so the gap
  holds when the finger is in the middle of the screen.
- A taller wave is wider (a third of its reach), so the neighbouring letters bend around
  the thumb instead of vanishing under it.
- A soft dark spot behind the selected letter keeps it readable where it lands on an app
  name or a light wallpaper.

Checked on the Android 17 emulator with touches injected at millisecond timing (a monkey
script) and a thumb-sized shape drawn over the screenshots.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 08:02:20 +01:00
TMC 1f9116d666 Release 0.3.0 2026-11-12 00:05:16 +01:00
TMCandClaude Fable 5.2 4e48615b5a Show an app's latest notification under its name in the drawer list
PRD AD-11, the last buildable row of M4.

- FlexeraNotificationPreview keeps the text of the notifications that currently show a
  dot, fed by Launcher3's NotificationListener on its worker thread, and sets a second,
  smaller and dimmer line on a drawer row whenever the row's dot state is applied.
- It uses what the dots already use: the same notification access and the same filter
  for which notifications count. Without notification access nothing changes.
- PopupDataProvider now refreshes an app's icons on every posted notification, so a
  notification updated in place brings its new text to the row.
- Only rows of the drawer list take the second line (marked with a view tag); search
  result icons share the "all apps" display type but stay as they were.
- New setting under Settings > App drawer: Notification previews, on by default.

Checked on the Android 17 emulator (debug build): a test SMS showed under Messages,
a second one replaced it while the drawer was open, removing the notification put the
plain name back, and the setting switches the line off and on.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 00:05:08 +01:00
21 changed files with 578 additions and 85 deletions

No files matched your search

+2
View File
@@ -494,6 +494,8 @@ dependencies {
implementation libs.kdrag0n.colorkt
implementation libs.coil.compose
implementation libs.xdrop.fuzzywuzzy
// Flexera Launcher: encodes the QR code on the About screen. Nothing else of it is used.
implementation libs.zxing.core
implementation libs.bundles.opto
implementation libs.androidx.datastore.preferences
+4 -1
View File
@@ -170,7 +170,10 @@ if ($SkipBuild) {
Get-ChildItem (Join-Path $Root 'build\kotlin') -Directory -Filter "compile${Variant}Kotlin*" -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force
$env:JAVA_HOME = $JavaHome
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain"
# "2>&1" inside cmd: Gradle prints compiler warnings on its error stream. If this script's
# output is piped or sent to a file, PowerShell would otherwise take the first such line
# for an error and stop the build. Only the exit code says whether the build failed.
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain 2>&1"
if ($LASTEXITCODE -ne 0) { throw "The build failed." }
}
if (-not (Test-Path $apk)) { throw "Expected the APK at $apk, but it is not there." }
+43 -18
View File
@@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Drawer list (PRD AD-2, AD-3). Rows are sized for thumb taps; 48dp is Android's minimum target. -->
<dimen name="flexera_drawer_row_height">64dp</dimen>
<!-- Smaller than a home-screen icon, so rows have air between them. -->
<dimen name="flexera_drawer_row_icon_size">46dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">20dp</dimen>
<dimen name="flexera_drawer_row_text_size">17sp</dimen>
<!-- Drawer list (PRD AD-2, AD-3). A compact list, at the owner's request: more apps within a
thumb's reach. The row stays just above 48dp, Android's minimum for a tap target.
(First version: 64dp rows, 46dp icons, 17sp names.) -->
<dimen name="flexera_drawer_row_height">50dp</dimen>
<!-- Well under a home-screen icon, so rows have air between them. -->
<dimen name="flexera_drawer_row_icon_size">34dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">16dp</dimen>
<dimen name="flexera_drawer_row_text_size">15sp</dimen>
<!-- Row padding on the edge without the wave alphabet. The search bar lines up with it. -->
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
<!-- Row padding on the wave alphabet's edge, which is the end edge unless the owner moves it
@@ -18,24 +20,47 @@
<!-- Where the letters rest, measured from the edge. -->
<dimen name="flexera_wave_edge_inset">16dp</dimen>
<dimen name="flexera_wave_letter_size">12sp</dimen>
<!-- Letters never sit further apart than this, however few there are. -->
<dimen name="flexera_wave_max_spacing">30dp</dimen>
<!-- Letters never sit further apart than this, however few there are. Close together, a
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
the stretch of edge a thumb covers without shifting the grip. (First version: 30dp,
which spread the alphabet over the whole edge.) -->
<dimen name="flexera_wave_max_spacing">20dp</dimen>
<!-- The stretch of edge kept for the alphabet is as tall as 26 letters and sits low: its end
this far above the navigation bar (on top of the vertical margin). The letters that
exist are centred in it. -->
<dimen name="flexera_wave_bottom_offset">40dp</dimen>
<dimen name="flexera_wave_vertical_margin">16dp</dimen>
<!-- How far before the finger the wave's peak sits, so the thumb never covers the letter. -->
<dimen name="flexera_wave_finger_gap">52dp</dimen>
<!-- How far beyond the finger the wave's peak sits, so the thumb never covers the selected
letter. A thumb hides about 11 mm (70dp) to each side of the point it touches; the
letter's centre has to clear that by its own half width and a margin. At 52dp, the
first value, the letter sat under the thumb. -->
<dimen name="flexera_wave_finger_gap">104dp</dimen>
<!-- The smallest bulge, used when the finger is right on the edge. -->
<dimen name="flexera_wave_min_reach">60dp</dimen>
<!-- How far up and down the edge the bulge spreads. -->
<dimen name="flexera_wave_width">56dp</dimen>
<dimen name="flexera_wave_min_reach">96dp</dimen>
<!-- How far up and down the edge the bulge spreads, at the least: wide, so that about five
letters on each side of the finger ride the wave and curve around the thumb, as in
Niagara Launcher. A taller wave is wider (WaveAlphabetView.WAVE_WIDTH_SHARE_OF_REACH). -->
<dimen name="flexera_wave_width">104dp</dimen>
<!-- The wave's peak never comes closer than this to the opposite edge of the screen. It
leaves room for the bubble beyond the peak. -->
<dimen name="flexera_wave_far_margin">66dp</dimen>
<!-- The bubble with the selected letter: its size, how far beyond the wave's peak its
centre sits, and how close it may come to a screen edge. -->
<dimen name="flexera_wave_bubble_radius">20dp</dimen>
<dimen name="flexera_wave_bubble_gap">38dp</dimen>
<dimen name="flexera_wave_bubble_text_size">22sp</dimen>
<dimen name="flexera_wave_bubble_edge_margin">6dp</dimen>
<dimen name="flexera_wave_bubble_shadow_radius">6dp</dimen>
<!-- Swipe up and hold (PRD AD-17): a pause only counts once the swipe has travelled this far. -->
<dimen name="flexera_hold_min_swipe">72dp</dimen>
<!-- Swipe up and hold (PRD AD-17): a pause only counts once the swipe has travelled this far.
Enough to tell a hold from a hesitant start, short enough that a small swipe counts. -->
<dimen name="flexera_hold_min_swipe">32dp</dimen>
<!-- Soft shadow behind white drawer text, for readability over light wallpaper. -->
<dimen name="flexera_drawer_text_shadow_radius">3dp</dimen>
<dimen name="flexera_drawer_text_shadow_dy">1dp</dimen>
<dimen name="flexera_drawer_header_height">44dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom">6dp</dimen>
<dimen name="flexera_drawer_header_text_size">14sp</dimen>
<dimen name="flexera_drawer_header_height">34dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom">4dp</dimen>
<dimen name="flexera_drawer_header_text_size">12sp</dimen>
</resources>
+4
View File
@@ -2,4 +2,8 @@
<resources>
<!-- The wave alphabet view inside the drawer (app.flexera.drawer.WaveAlphabetView). -->
<item name="flexera_wave_alphabet" type="id" />
<!-- View tag that marks an app row of the drawer list (FlexeraDrawer.styleAppRow). -->
<item name="flexera_drawer_row" type="id" />
<!-- View tag on a drawer row: set while the row shows a notification preview under the app name. -->
<item name="flexera_notification_preview" type="id" />
</resources>
+5
View File
@@ -27,6 +27,9 @@
<string name="flexera_about_built_on_title">Lawnchair</string>
<string name="flexera_about_built_on_body">Flexera Launcher is a modified version of Lawnchair, the open-source launcher made by the Lawnchair team. Lawnchair is itself based on Launcher3 from the Android Open Source Project.</string>
<string name="flexera_about_lawnchair_source">Lawnchair source code</string>
<string name="flexera_about_share">Share</string>
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
<string name="flexera_about_share_qr_description">QR code that opens the download page</string>
<string name="flexera_about_privacy">Privacy</string>
<string name="flexera_about_privacy_title">Nothing about you leaves your phone</string>
<string name="flexera_about_privacy_body">Flexera Launcher goes online for one thing: asking its release page whether a newer version exists, about once a day, and downloading that version when you ask. It sends nothing about you, your apps or your phone.</string>
@@ -40,6 +43,8 @@
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
<string name="flexera_alphabet_description">Alphabet</string>
<!-- App drawer settings (app.flexera.ui.FlexeraDrawerSettings) -->
<string name="flexera_notification_previews">Notification previews</string>
<string name="flexera_notification_previews_description">Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General).</string>
<string name="flexera_alphabet_heading">Alphabet</string>
<string name="flexera_alphabet_on_left">Alphabet on the left edge</string>
<string name="flexera_alphabet_on_left_description">For left-handed use. The letters normally sit on the right edge.</string>
@@ -17,6 +17,7 @@ import com.android.launcher3.R
import com.android.launcher3.allapps.ActivityAllAppsContainerView
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
import com.android.launcher3.views.BaseDragLayer
import kotlin.math.max
/**
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
@@ -87,9 +88,18 @@ object FlexeraDrawer {
@JvmStatic
fun styleAppRow(icon: BubbleTextView) {
val res = icon.resources
icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height)
// Other icons share the "all apps" display type (search results, for one); this tag
// tells list rows apart from them.
icon.setTag(R.id.flexera_drawer_row, true)
val textSize = res.getDimension(R.dimen.flexera_drawer_row_text_size)
// The row has to hold the name with a notification preview under it (PRD AD-11). At the
// usual font size the set height does; with a large system font the row grows instead.
icon.layoutParams.height = max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(textSize * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.flexera_drawer_row_text_size))
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
applySidePadding(icon)
// The drawer has no background, so names sit directly on the dimmed wallpaper: white,
// with a soft shadow to stay readable over light areas.
@@ -105,6 +115,16 @@ object FlexeraDrawer {
/** Shadow behind white drawer text. */
const val TEXT_SHADOW_COLOR = 0x73000000
/** The least a row's height may be, in name text sizes: two lines of text and some air. */
private const val ROW_HEIGHT_IN_TEXT_SIZES = 2.4f
/**
* What lies between the open drawer and the blurred wallpaper: black at 78%, whatever the
* theme and the wallpaper. Lawnchair's backdrop is a grey at 40%, which over a light
* wallpaper leaves a light grey behind the drawer's white names.
*/
const val BACKDROP_COLOR = 0xC7000000.toInt()
/**
* Lawnchair fades the search field's pill out while the owner types, leaving the text on the
* drawer's panel. This drawer has no panel, so the pill stays.
@@ -197,12 +217,19 @@ object FlexeraDrawer {
appsView: ActivityAllAppsContainerView<*>,
dragLayer: BaseDragLayer<*>,
ev: MotionEvent,
) = scrubFromSwipe(appsView, dragLayer, ev.x, ev.y)
/** The same, for a finger position ([x], [y]) in the drag layer's coordinates. */
@JvmStatic
fun scrubFromSwipe(
appsView: ActivityAllAppsContainerView<*>,
dragLayer: BaseDragLayer<*>,
x: Float,
y: Float,
) {
val wave = waveAlphabet(appsView) ?: return
val offset = layoutOffset(wave, dragLayer)
val x = ev.x - offset.x
val y = ev.y - offset.y
if (wave.isScrubbing) wave.moveScrub(x, y) else wave.beginScrub(x, y)
if (wave.isScrubbing) wave.moveScrub(x - offset.x, y - offset.y) else wave.beginScrub(x - offset.x, y - offset.y)
}
/**
@@ -236,12 +263,27 @@ object FlexeraDrawer {
return offset
}
/**
* A scrub that started from a swipe and ends sooner than this was not meant as one: nobody
* reads a letter and means it in a fifth of a second. The swipe opens the plain list.
*/
const val MIN_SCRUB_MS = 200L
/** Ends a scrub started by [scrubFromSwipe]; the drawer stays open at the chosen letter. */
@JvmStatic
fun endScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
waveAlphabet(appsView)?.endScrub()
}
/**
* Ends a scrub started by [scrubFromSwipe] and undoes it, so the drawer opens the way an
* ordinary swipe up leaves it.
*/
@JvmStatic
fun cancelScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
waveAlphabet(appsView)?.cancelScrub()
}
/**
* A letter header. It carries its letter so that the adapter's diffing notices when the
* header at a position changes, for example after an app is installed or removed.
@@ -1,5 +1,6 @@
package app.flexera.drawer
import android.os.SystemClock
import android.view.MotionEvent
import android.view.ViewConfiguration
import app.lawnchair.preferences.PreferenceManager
@@ -16,7 +17,9 @@ import kotlin.math.abs
* touch. Nothing is drawn on the home screen; the letters appear with the drawer.
*
* The edge is shared with page swipes, taps and item drags, so this claims a touch only once it
* has clearly moved up or down, and never while an item is being dragged. It is off by default.
* has clearly moved up or down, and never while an item is being dragged. A touch that lifts
* again within [FlexeraDrawer.MIN_SCRUB_MS] was a flick, not a scrub, and opens the plain list.
* The setting is on unless the owner switches it off.
*/
class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchController {
@@ -26,6 +29,7 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
/** The current touch started on the edge and may still turn into a scrub. */
private var candidate = false
private var scrubbing = false
private var scrubStartTime = 0L
private var downX = 0f
private var downY = 0f
@@ -48,7 +52,11 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
dy > touchSlop && dy > dx * VERTICAL_BIAS -> {
candidate = false
scrubbing = true
launcher.stateManager.goToState(LauncherState.ALL_APPS)
scrubStartTime = SystemClock.uptimeMillis()
// The drawer starts opening and the alphabet takes the finger in the same
// moment. Opening comes first, because its start is when the drawer
// puts its list in place, which must not undo the scrub's first jump.
openDrawer()
FlexeraDrawer.scrubFromSwipe(launcher.appsView, launcher.dragLayer, ev)
return true
}
@@ -68,12 +76,28 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
scrubbing = false
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
if (SystemClock.uptimeMillis() - scrubStartTime < FlexeraDrawer.MIN_SCRUB_MS) {
// A flick up that happened to start on the edge: open the plain list.
FlexeraDrawer.cancelScrubFromSwipe(launcher.appsView)
} else {
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
}
}
}
return true
}
/**
* Opens the drawer in [OPEN_MS]. Asking the state manager to go there would take the
* drawer's own 600 ms, far too slow for a list that the finger is already scrubbing.
*/
private fun openDrawer() {
launcher.stateManager.createAnimationToNewWorkspace(LauncherState.ALL_APPS, OPEN_MS).apply {
dispatchOnStart()
start()
}
}
private fun canStart(ev: MotionEvent): Boolean =
prefs.flexeraScrubFromHome.get() &&
(ev.edgeFlags and Utilities.EDGE_NAV_BAR) == 0 &&
@@ -86,5 +110,8 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
private companion object {
/** A slide counts as vertical when it has moved this many times further up or down than sideways. */
const val VERTICAL_BIAS = 2f
/** How long the drawer takes to open under a scrub; the same as after swipe up and hold. */
const val OPEN_MS = 120L
}
}
@@ -0,0 +1,132 @@
package app.flexera.drawer
import android.app.Notification
import android.service.notification.StatusBarNotification
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextUtils
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import app.lawnchair.preferences.PreferenceManager
import com.android.launcher3.BubbleTextView
import com.android.launcher3.R
import com.android.launcher3.dot.DotInfo
import com.android.launcher3.model.data.ItemInfo
import java.util.concurrent.ConcurrentHashMap
/**
* Inline notification previews (PRD AD-11): in the drawer list, a row whose app has
* notifications shows the latest one under the app name, smaller and dimmer.
*
* It rides on what Launcher3 already has for notification dots. The same notification access
* feeds it, the same filter decides which notifications count, and a row is refreshed whenever
* its dot is. Without notification access there are no dots and no previews.
*
* Launcher3's listener hands each notification to [onPosted], [onRemoved] or [onFullRefresh] on
* its worker thread; the rows read the stored text on the main thread through [apply].
*/
object FlexeraNotificationPreview {
private class Preview(val text: String, val postTime: Long)
/** Latest text per notification key, for the notifications that currently show a dot. */
private val previews = ConcurrentHashMap<String, Preview>()
private const val MAX_LENGTH = 140
/** The preview line's text size as a share of the app name's. */
private const val TEXT_SCALE = 0.78f
/** White at 70%, so the app name stays the stronger of the two lines. */
private const val TEXT_COLOR = 0xB3FFFFFF.toInt()
@JvmStatic
fun onPosted(sbn: StatusBarNotification) {
val text = previewText(sbn)
if (text == null) previews.remove(sbn.key) else previews[sbn.key] = Preview(text, sbn.postTime)
}
@JvmStatic
fun onRemoved(sbn: StatusBarNotification) {
previews.remove(sbn.key)
}
@JvmStatic
fun onFullRefresh(active: List<StatusBarNotification>) {
previews.clear()
active.forEach(::onPosted)
}
/**
* Sets [icon]'s text for [info]: the app name, plus the latest notification under it when
* [dot] says the app has any. Called for drawer rows whenever their dot state is applied.
*/
@JvmStatic
fun apply(icon: BubbleTextView, info: ItemInfo, dot: DotInfo?) {
// Only rows of the drawer list have room for a second line.
if (icon.getTag(R.id.flexera_drawer_row) == null) return
val name = info.title ?: return
val preview = if (dot != null && isEnabled(icon)) latest(dot) else null
if (preview == null) {
if (icon.getTag(R.id.flexera_notification_preview) != null) {
// The row was showing a preview, for this app earlier or for another app before
// the view was reused. Back to the plain one-line name.
icon.setTag(R.id.flexera_notification_preview, null)
icon.setLines(1)
icon.text = name
}
return
}
// The text is set afresh every time. Binding a row puts the plain name back first, so
// what the row showed before says nothing about what it shows now.
// Keep a long app name on its own line, so the preview is never pushed off the row.
val width = icon.width - icon.compoundPaddingLeft - icon.compoundPaddingRight
val firstLine = if (width > 0) {
TextUtils.ellipsize(name, icon.paint, width.toFloat(), TextUtils.TruncateAt.END)
} else {
name
}
val label = SpannableStringBuilder(firstLine).append('\n')
val start = label.length
label.append(preview)
label.setSpan(RelativeSizeSpan(TEXT_SCALE), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
label.setSpan(ForegroundColorSpan(TEXT_COLOR), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
icon.setLines(2)
icon.ellipsize = TextUtils.TruncateAt.END
icon.text = label
icon.setTag(R.id.flexera_notification_preview, true)
}
private fun isEnabled(icon: BubbleTextView): Boolean =
PreferenceManager.getInstance(icon.context).flexeraNotificationPreviews.get()
/** The text of the newest of the app's notifications that has any text. */
private fun latest(dot: DotInfo): String? {
var newest: Preview? = null
for (key in dot.notificationKeys) {
val candidate = previews[key.notificationKey] ?: continue
if (newest == null || candidate.postTime > newest.postTime) newest = candidate
}
return newest?.text
}
/** "Title: text" in one line, or whichever of the two the notification has. */
private fun previewText(sbn: StatusBarNotification): String? {
val extras = sbn.notification.extras ?: return null
val title = extras.getCharSequence(Notification.EXTRA_TITLE).oneLine()
val text = extras.getCharSequence(Notification.EXTRA_TEXT).oneLine()
val combined = when {
title.isEmpty() -> text
text.isEmpty() -> title
else -> "$title: $text"
}
return combined.take(MAX_LENGTH).ifEmpty { null }
}
private fun CharSequence?.oneLine(): String =
this?.toString()?.replace('\n', ' ')?.trim().orEmpty()
}
@@ -24,6 +24,8 @@ import androidx.core.view.doOnNextLayout
import androidx.customview.widget.ExploreByTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.R
import com.android.launcher3.allapps.ActivityAllAppsContainerView
import kotlin.math.abs
@@ -37,11 +39,14 @@ import kotlin.math.roundToInt
*
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
* to the letter under the finger, with a haptic tick on every change. While the finger is down,
* the letters near it bulge away from the edge in a wave, and the wave reaches further the
* further the finger moves in from the edge, so the current letter always sits beside the thumb
* instead of under it.
* the letters near it bulge away from the edge in a wide wave that curves around the thumb.
* The wave's peak, where the current letter is, stays a thumb's width beyond the finger however
* far in from the edge the finger is, and just beyond the peak a bubble shows that letter
* large. The look follows Niagara Launcher's alphabet, the owner's reference.
*
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15).
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15), and low
* on that edge, with its letters close together, so that the first letter is as easy to reach
* as the last.
*
* The view covers the whole drawer so it has room to draw the wave, but it only takes touches
* that start on the strip; every other touch falls through to the list underneath.
@@ -66,9 +71,11 @@ class WaveAlphabetView(
private val edgeInset = res.getDimension(R.dimen.flexera_wave_edge_inset)
private val maxSpacing = res.getDimension(R.dimen.flexera_wave_max_spacing)
private val verticalMargin = res.getDimension(R.dimen.flexera_wave_vertical_margin)
private val bottomOffset = res.getDimension(R.dimen.flexera_wave_bottom_offset)
private val fingerGap = res.getDimension(R.dimen.flexera_wave_finger_gap)
private val minReach = res.getDimension(R.dimen.flexera_wave_min_reach)
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
/** The section name Launcher3 gives the work profile's info card, an "i" in a circle. */
@@ -91,6 +98,27 @@ class WaveAlphabetView(
}
private val boldTypeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
// The bubble that shows the selected letter large, beyond the wave's peak: a light disc
// with a dark letter, the one thing on the dark drawer that is not white on dark.
private val bubbleRadius = res.getDimension(R.dimen.flexera_wave_bubble_radius)
private val bubbleGap = res.getDimension(R.dimen.flexera_wave_bubble_gap)
private val bubbleTextSize = res.getDimension(R.dimen.flexera_wave_bubble_text_size)
private val bubbleEdgeMargin = res.getDimension(R.dimen.flexera_wave_bubble_edge_margin)
private val bubblePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = BUBBLE_COLOR
setShadowLayer(
res.getDimension(R.dimen.flexera_wave_bubble_shadow_radius),
0f,
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
BUBBLE_SHADOW_COLOR,
)
}
private val bubbleTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = BUBBLE_TEXT_COLOR
textAlign = Paint.Align.CENTER
typeface = boldTypeface
}
private var letters: List<Letter> = emptyList()
private var letterYs = FloatArray(0)
private var stripTop = 0f
@@ -176,8 +204,14 @@ class WaveAlphabetView(
} else {
val spacing = min(maxSpacing, (availableBottom - availableTop) / count)
val blockHeight = spacing * count
// Few letters: keep the block in the middle of the edge, where a thumb rests.
stripTop = availableTop + (availableBottom - availableTop - blockHeight) / 2f
// The stretch of edge a whole alphabet would take sits low, where a thumb reaches
// without stretching: its end a little above the navigation bar, its start
// wherever that puts it. The letters there are sit in the middle of that stretch,
// so that a short alphabet is in the middle of the thumb's reach and not at its
// lower end. Only letters that need the whole edge reach up to where the list starts.
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * FULL_ALPHABET))
val zoneBottom = availableBottom - min(bottomOffset, availableBottom - availableTop - zoneHeight)
stripTop = zoneBottom - zoneHeight + (zoneHeight - blockHeight) / 2f
stripBottom = stripTop + blockHeight
for (i in 0 until count) {
letterYs[i] = stripTop + spacing * (i + 0.5f)
@@ -225,29 +259,36 @@ class WaveAlphabetView(
// Where the letters rest, and which way "away from the edge" points.
val baseX = if (onLeft) edgeInset else width - edgeInset
val inward = if (onLeft) 1f else -1f
// The wave's peak sits a fixed gap before the finger, so it reaches further out the
// further the finger is from the edge.
// The wave's peak sits a fixed gap beyond the finger, on the side away from the edge,
// which is the side the thumb leaves uncovered. So the wave reaches further out the
// further the finger is from the edge, up to nearly the whole width of the screen.
val fingerDistance = (fingerX - baseX) * inward
val maxReach = width * 0.7f
val maxReach = max(minReach + 1f, width - edgeInset - farMargin)
val fullReach = (fingerDistance + fingerGap).coerceIn(minReach, maxReach)
val reach = fullReach * press
// The wave is wide: several letters on each side of the finger ride it, so they curve
// around the whole thumb and stay readable above and below it. A wave that reaches
// far out is wider still, which keeps its shape.
val spread = max(waveWidth, fullReach * WAVE_WIDTH_SHARE_OF_REACH)
// Darken the list under the wave where its letters cross icons and names. From the
// right edge that only happens once the wave reaches well into the screen, as it does
// after swipe up and hold; from the left edge the icons sit right beside the strip.
// Darken the list a little while scrubbing, so the eye goes to the letters, and more
// where the wave lies over icons and names: from the right edge that is once it reaches
// well into the screen, as after swipe up and hold; from the left edge it is at once.
if (press > 0f) {
val overList = if (onLeft) 1f else ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
val reachShare = ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
val overList = if (onLeft) 1f else SCRUB_DIM_AT_EDGE + (1f - SCRUB_DIM_AT_EDGE) * reachShare
val dim = (SCRUB_DIM * press * overList * 255).toInt()
if (dim > 0) canvas.drawColor(Color.argb(dim, 0, 0, 0))
}
for (i in letters.indices) {
val y = letterYs[i]
val nearness = if (press > 0f) bell((y - fingerY) / waveWidth) else 0f
val nearness = if (press > 0f) bell((y - fingerY) / spread) else 0f
val isSelected = i == selected && press > 0f
val scale = 1f + (if (isSelected) SELECTED_GROWTH else NEAR_GROWTH) * nearness * press
paint.textSize = restingLetterSize * scale
// Letters keep their size in the wave, so close neighbours never collide; the
// selected one is bold and a touch larger. Its large copy is in the bubble.
paint.textSize = restingLetterSize * (1f + (if (isSelected) SELECTED_GROWTH * press else 0f))
paint.typeface = if (isSelected) boldTypeface else Typeface.DEFAULT
paint.color = if (isSelected) {
activeColor
@@ -260,6 +301,20 @@ class WaveAlphabetView(
val baseline = y - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(letters[i].label, x, baseline, paint)
}
// The selected letter, large, in a bubble just beyond the wave's peak and level with
// the finger: the furthest thing from the thumb, so the thumb cannot hide it.
if (press > 0f && selected in letters.indices) {
val edgeRoom = bubbleRadius + bubbleEdgeMargin
val cx = (baseX + inward * (reach + bubbleGap * press)).coerceIn(edgeRoom, max(edgeRoom, width - edgeRoom))
val alpha = (press * 255).toInt()
bubblePaint.alpha = alpha
canvas.drawCircle(cx, fingerY, bubbleRadius * press, bubblePaint)
bubbleTextPaint.alpha = alpha
bubbleTextPaint.textSize = bubbleTextSize * press
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
}
}
private fun bell(distance: Float): Float = exp(-distance * distance)
@@ -316,6 +371,27 @@ class WaveAlphabetView(
moveScrub(x, y)
}
/**
* Ends a scrub that began with the drawer closed and undoes it: the list goes to where a
* drawer opened by a plain swipe stands, which is the top unless the owner has the drawer
* remember its place. For a scrub that turns out to have been started by mistake.
*
* The list is not asked where it stood before the scrub. A closed drawer has been told to
* go back to the top but lays its rows out again only when it opens, so until then the
* rows still report the place the drawer was left at.
*/
fun cancelScrub() {
if (!scrubbing) return
endScrub()
val rv = appsView.activeRecyclerView ?: return
rv.stopScroll()
if (PreferenceManager2.getInstance(context).rememberPosition.firstCached()) {
rv.restoreScrollPosition()
} else {
rv.scrollToTop()
}
}
/** Moves the finger of a scrub started with [beginScrub]. */
fun moveScrub(x: Float, y: Float) {
if (!scrubbing || letters.isEmpty()) return
@@ -459,15 +535,26 @@ class WaveAlphabetView(
}
private companion object {
/** How much larger than its resting size the selected letter grows. */
const val SELECTED_GROWTH = 1.5f
/** How many letters a whole alphabet has; the stretch of edge kept for the strip is sized for it. */
const val FULL_ALPHABET = 26
/** How much its neighbours grow, tapering with distance from the finger. */
const val NEAR_GROWTH = 0.5f
/** How much larger than its resting size the selected letter is in the wave itself. */
const val SELECTED_GROWTH = 0.25f
/** The bubble: a near-white disc, its letter near-black, a soft shadow under it. */
const val BUBBLE_COLOR = 0xFFF1F1F4.toInt()
const val BUBBLE_TEXT_COLOR = 0xFF17181C.toInt()
const val BUBBLE_SHADOW_COLOR = 0x66000000
/** The wave's spread up and down the edge, as a share of how far it reaches out. */
const val WAVE_WIDTH_SHARE_OF_REACH = 0.36f
/** How dark the list gets under a wave that lies fully over it (0 = unchanged, 1 = black). */
const val SCRUB_DIM = 0.55f
/** The share of [SCRUB_DIM] applied when the wave stays at the right edge, clear of the list. */
const val SCRUB_DIM_AT_EDGE = 0.35f
/** The tallest a resting letter may be, as a share of the vertical room it has. */
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.text.format.Formatter
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -30,9 +32,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -54,6 +61,9 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
import app.lawnchair.ui.preferences.navigation.AboutLicenses
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import kotlin.math.floor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -62,6 +72,12 @@ private const val LAWNCHAIR_SOURCE_URL = "https://github.com/LawnchairLauncher/l
/** Release notes longer than this are cut off; the full text is on the release page. */
private const val MAX_NOTES_LENGTH = 600
/** Large enough to scan from another phone held at arm's length. */
private val QR_CODE_SIZE = 220.dp
/** The blank border around a QR code, in modules; the standard asks for four. */
private const val QR_QUIET_ZONE_MODULES = 4
/**
* Flexera Launcher's About screen.
*
@@ -178,6 +194,9 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_update_heading)) }
groupRows(updateRows)
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
groupRows(
listOf<@Composable () -> Unit>(
@@ -295,6 +314,71 @@ private fun UpdateStatusRow(state: UpdateState) {
}
}
/**
* What the owner holds up for someone who wants the app: a QR code that opens the release page,
* with the address under it for anyone who would rather type it.
*/
@Composable
private fun ShareCard(url: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCode(
text = url,
description = stringResource(id = R.string.flexera_about_share_qr_description),
modifier = Modifier.size(QR_CODE_SIZE),
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.flexera_about_share_title),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Text(
text = url.removePrefix("https://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* A QR code for [text], made on the phone. It is black on white in every theme, with the blank
* border the standard asks for, because that is what scanners read most reliably.
*/
@Composable
private fun QrCode(text: String, description: String, modifier: Modifier = Modifier) {
val modules = remember(text) { Encoder.encode(text, ErrorCorrectionLevel.M).matrix }
Canvas(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.semantics { contentDescription = description },
) {
drawRect(Color.White)
// Whole pixels per module, so neighbouring modules join without a seam.
val count = modules.width + 2 * QR_QUIET_ZONE_MODULES
val module = floor(size.minDimension / count)
val left = floor((size.width - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
val top = floor((size.height - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
for (y in 0 until modules.height) {
for (x in 0 until modules.width) {
if (modules.get(x, y).toInt() == 1) {
drawRect(
color = Color.Black,
topLeft = Offset(left + x * module, top + y * module),
size = Size(module, module),
)
}
}
}
}
}
@Composable
private fun downloadSizeText(context: Context, release: FlexeraRelease): String =
if (release.apkSize > 0) {
@@ -49,6 +49,11 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
label = stringResource(id = R.string.suggestion_pref_screen_title),
destination = Predictions,
)
SwitchPreference(
label = stringResource(id = R.string.flexera_notification_previews),
description = stringResource(id = R.string.flexera_notification_previews_description),
adapter = prefs.flexeraNotificationPreviews.getAdapter(),
)
SwitchPreference(
label = stringResource(id = R.string.pref_all_apps_remember_position_title),
description = stringResource(id = R.string.pref_all_apps_remember_position_description),
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.2.0
versionName=0.4.0
+2
View File
@@ -128,6 +128,8 @@ coil-compose = "io.coil-kt:coil-compose:2.7.0"
google-material = "com.google.android.material:material:1.14.0"
hoko-blur = "io.github.hokofly:hoko-blur:1.5.5"
javax-inject = "javax.inject:javax.inject:1"
# Flexera Launcher: QR code on the About screen (app.flexera.ui.FlexeraAbout).
zxing-core = "com.google.zxing:core:3.5.3"
kdrag0n-colorkt = "dev.kdrag0n:colorkt:1.0.5"
leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.14"
leakcanary-androidstudio = "com.android.tools.studio.leakcanary:leakcanary:1.0.0"
@@ -218,7 +218,11 @@ class PreferenceManager @Inject constructor(
val flexeraAlphabetOnLeft = BoolPref("pref_flexeraAlphabetOnLeft", false, recreate)
// Flexera Launcher: whether a slide along the home screen's edge opens the drawer at a letter (PRD AD-10).
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false)
// On unless switched off, by the owner's decision of 12 November 2026.
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", true)
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
override fun close() {
TODO("Not yet implemented")
@@ -3,6 +3,7 @@ package app.lawnchair.views
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import app.flexera.drawer.FlexeraDrawer
import app.lawnchair.preferences.PreferenceManager
import com.android.launcher3.R
import com.android.launcher3.util.SystemUiController
@@ -24,7 +25,10 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
val threshold = STATUS_BAR_COLOR_FORCE_UPDATE_THRESHOLD
val forceChange = visibility == VISIBLE &&
alpha > threshold &&
Color.alpha(mBackgroundColor) / (255f * drawerOpacity) > threshold
// Flexera Launcher: the scrim is at full strength when it reaches the alpha of the
// drawer's backdrop. Lawnchair measured against its drawer opacity setting, which
// is 0 here.
Color.alpha(mBackgroundColor) / Color.alpha(FlexeraDrawer.BACKDROP_COLOR).toFloat() > threshold
with(systemUiController) {
if (forceChange) {
updateUiState(SystemUiController.UI_STATE_SCRIM_VIEW, !isScrimDark)
@@ -34,9 +38,8 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
}
}
override fun isScrimDark() = if (drawerOpacity <= 0.3f) {
!Themes.getAttrBoolean(context, R.attr.isWorkspaceDarkText)
} else {
super.isScrimDark()
}
// Flexera Launcher: judged by the scrim's own colour, as Launcher3 does. Lawnchair went by
// the wallpaper here because its drawer scrim could be see-through; this drawer's backdrop
// is always dark, and the status bar icons over it have to be light.
override fun isScrimDark() = super.isScrimDark()
}
@@ -219,6 +219,9 @@ public class AllAppsState extends LauncherState {
} else {
backgroundColor = ColorTokens.AllAppsScrimColor.resolveColor(launcher);
}
// Flexera Launcher: the drawer's backdrop is dark whatever the theme and the wallpaper,
// so that the white names on it stay readable.
backgroundColor = app.flexera.drawer.FlexeraDrawer.BACKDROP_COLOR;
return new ScrimColors(backgroundColor, /* foregroundColor */ Color.TRANSPARENT);
}
}
@@ -17,6 +17,7 @@
package com.android.launcher3.uioverrides.touchcontrollers;
import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE;
import static com.android.app.animation.Interpolators.DECELERATE;
import static com.android.launcher3.LauncherAnimUtils.SCRIM_COLORS;
import static com.android.launcher3.LauncherAnimUtils.newSingleUseCancelListener;
import static com.android.launcher3.LauncherState.ALL_APPS;
@@ -35,6 +36,7 @@ import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.graphics.PointF;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
@@ -81,10 +83,23 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
private boolean mDidTouchStartInNavBar;
// Flexera Launcher: swipe up and hold hands the same touch to the wave alphabet (PRD AD-17).
// "Started" is set when the pause is detected and the drawer begins to finish opening;
// "ready" is set once it is fully open and the alphabet can take the finger's position.
// "Started" is set when the pause is detected: the alphabet takes the finger at once, while
// the drawer finishes opening under it. "Drawer open" is set when that has finished.
private boolean mFlexeraScrubStarted;
private boolean mFlexeraScrubReady;
private boolean mFlexeraDrawerOpen;
// Where the finger was at the last drag event, in the drag layer's coordinates.
private float mFlexeraLastX;
private float mFlexeraLastY;
// How long the drawer takes to finish opening once a hold is detected. Short, because the
// alphabet is already following the finger and the list has to be readable behind it.
private static final long FLEXERA_HOLD_OPEN_MS = 120;
// A finger that has not moved for this long is holding. Swipe to recents waits twice as
// long; here a false alarm only opens the drawer the swipe was opening anyway.
private static final long FLEXERA_HOLD_STILL_MS = 150;
// A finger that lifts sooner than this after the hold was noticed was not holding; see
// onDragEnd. Too short a time to have read the letter and meant it.
private static final long FLEXERA_MIN_HOLD_MS = app.flexera.drawer.FlexeraDrawer.MIN_SCRUB_MS;
private long mFlexeraScrubStartTime;
private boolean mStartedOverview;
private boolean mReachedOverview;
// The last recorded displacement before we reached overview.
@@ -177,9 +192,10 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
// Flexera Launcher: a pause during an ordinary swipe up to the drawer starts a scrub.
mFlexeraScrubStarted = false;
mFlexeraScrubReady = false;
mFlexeraDrawerOpen = false;
if (flexeraHoldToScrub()) {
mMotionPauseDetector.setOnMotionPauseListener(this::onFlexeraHoldDetected);
mMotionPauseDetector.setForcePauseTimeout(FLEXERA_HOLD_STILL_MS);
}
if (mFromState == NORMAL && mToState == HINT_STATE) {
@@ -219,8 +235,15 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
if (mFlexeraScrubStarted) {
// Flexera Launcher: lifting the finger ends the scrub and leaves the drawer open at
// the chosen letter. If the drawer is still opening, its end callback finishes up.
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
if (mFlexeraScrubReady) {
if (SystemClock.uptimeMillis() - mFlexeraScrubStartTime < FLEXERA_MIN_HOLD_MS) {
// The finger left almost as soon as the hold was noticed, so it was not
// holding: an ordinary swipe slowed down at its end. Open the drawer the way
// that swipe would have, with the list where it was.
app.flexera.drawer.FlexeraDrawer.cancelScrubFromSwipe(mLauncher.getAppsView());
} else {
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
}
if (mFlexeraDrawerOpen) {
onSwipeInteractionCompleted(ALL_APPS);
}
} else if (mStartedOverview) {
@@ -288,26 +311,34 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
}
/**
* Flexera Launcher: the finger paused part-way through a swipe up. Finish opening the drawer
* and, once it is open, let the same touch scrub the wave alphabet. This mirrors
* {@link #onMotionPauseDetected()}, which does the same hand-over to Overview.
* Flexera Launcher: the finger paused part-way through a swipe up. From this moment the same
* touch scrubs the wave alphabet, with no wait: the alphabet takes the finger where it is,
* and the drawer finishes opening under it.
*/
private void onFlexeraHoldDetected() {
if (mCurrentAnimation == null || mFlexeraScrubStarted || !flexeraHoldToScrub()) {
return;
}
mCurrentAnimation.getTarget().addListener(newSingleUseCancelListener(() ->
mLauncher.getStateManager().goToState(ALL_APPS, true, forSuccessCallback(() -> {
mFlexeraScrubReady = true;
if (!mDetector.isDraggingState()) {
// The finger lifted while the drawer was still opening.
onSwipeInteractionCompleted(ALL_APPS);
}
}))));
mCurrentAnimation.getTarget().removeListener(mClearStateOnCancelListener);
mCurrentAnimation.dispatchOnCancel();
mFlexeraScrubStarted = true;
mFlexeraScrubStartTime = SystemClock.uptimeMillis();
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
// Play the swipe's own animation to its end, as lifting the finger would, only faster.
// Going through the state manager instead would take its full 600 ms opening time.
mCurrentAnimation.setEndAction(() -> {
mFlexeraDrawerOpen = true;
if (!mDetector.isDraggingState()) {
// The finger lifted while the drawer was still opening.
onSwipeInteractionCompleted(ALL_APPS);
}
});
ValueAnimator anim = mCurrentAnimation.getAnimationPlayer();
anim.setFloatValues(
Utilities.boundToRange(mCurrentAnimation.getProgressFraction(), 0f, 1f), 1f);
anim.setDuration(FLEXERA_HOLD_OPEN_MS).setInterpolator(DECELERATE);
mCurrentAnimation.dispatchOnStart();
anim.start();
VibratorWrapper.INSTANCE.get(mLauncher).vibrate(OVERVIEW_HAPTIC);
}
@@ -326,13 +357,15 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
@Override
public boolean onDrag(float yDisplacement, float xDisplacement, MotionEvent event) {
// Flexera Launcher: remembered so that a hold, which can be noticed between two touch
// events, knows where the finger is.
mFlexeraLastX = event.getX();
mFlexeraLastY = event.getY();
if (mFlexeraScrubStarted) {
// Flexera Launcher: the swipe has become a scrub; the finger now moves the alphabet,
// not the drawer. Until the drawer has finished opening there is nothing to move yet.
if (mFlexeraScrubReady) {
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), event);
}
// not the drawer.
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
return true;
}
if (mStartedOverview) {
@@ -76,6 +76,9 @@ public class MotionPauseDetector {
private boolean mDisallowPause;
// Time at which speed became < mSpeedSlow (only used if mMakePauseHarderToTrigger == true).
private long mSlowStartTime;
// Flexera Launcher: how long without motion counts as a pause. A gesture may shorten it
// (see setForcePauseTimeout); clear() puts the stock value back.
private long mForcePauseTimeoutMs = FORCE_PAUSE_TIMEOUT;
public MotionPauseDetector(Context context) {
this(context, false);
@@ -122,6 +125,15 @@ public class MotionPauseDetector {
mIsTrackpadGesture = isTrackpadGesture;
}
/**
* Flexera Launcher: sets how long the finger must stay still before that alone counts as a
* pause, for the gesture in progress. Swipe up and hold (PRD AD-17) uses a shorter time
* than the stock 300 ms, so the alphabet answers a hold without a noticeable wait.
*/
public void setForcePauseTimeout(long timeoutMs) {
mForcePauseTimeoutMs = timeoutMs;
}
/**
* @param disallowPause If true, we will not detect any pauses until this is set to false again.
*/
@@ -153,7 +165,7 @@ public class MotionPauseDetector {
? TEST_HARNESS_TRIGGER_TIMEOUT
: mMakePauseHarderToTrigger
? HARDER_TRIGGER_TIMEOUT
: FORCE_PAUSE_TIMEOUT;
: mForcePauseTimeoutMs;
mForcePauseTimeout.setAlarm(timeoutMs);
float newVelocity = mVelocityProvider.addMotionEvent(ev, ev.getPointerId(pointerIndex));
if (mPreviousVelocity != null) {
@@ -262,6 +274,7 @@ public class MotionPauseDetector {
mIsTrackpadGesture = false;
mIsPaused = mHasEverBeenPaused = false;
mSlowStartTime = 0;
mForcePauseTimeoutMs = FORCE_PAUSE_TIMEOUT;
mForcePauseTimeout.cancelAlarm();
}
@@ -1413,6 +1413,11 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
setContentDescription(itemInfo.contentDescription);
}
}
// Flexera Launcher: a row in the drawer list shows its app's latest notification
// under the app name (PRD AD-11).
if (mDisplay == DISPLAY_ALL_APPS) {
app.flexera.drawer.FlexeraNotificationPreview.apply(this, itemInfo, mDotInfo);
}
}
}
@@ -129,13 +129,23 @@ public class NotificationListener extends NotificationListenerService {
switch (message.what) {
case MSG_NOTIFICATION_POSTED: {
StatusBarNotification sbn = (StatusBarNotification) message.obj;
mUiHandler.obtainMessage(notificationIsValidForUI(sbn)
// Flexera Launcher: keep the text for the drawer's notification previews in step
// with the dots, before the rows are told to refresh (PRD AD-11).
boolean validForUi = notificationIsValidForUI(sbn);
if (validForUi) {
app.flexera.drawer.FlexeraNotificationPreview.onPosted(sbn);
} else {
app.flexera.drawer.FlexeraNotificationPreview.onRemoved(sbn);
}
mUiHandler.obtainMessage(validForUi
? MSG_NOTIFICATION_POSTED : MSG_NOTIFICATION_REMOVED,
toKeyPair(sbn)).sendToTarget();
return true;
}
case MSG_NOTIFICATION_REMOVED: {
StatusBarNotification sbn = (StatusBarNotification) message.obj;
// Flexera Launcher: drop the notification's preview text with its dot.
app.flexera.drawer.FlexeraNotificationPreview.onRemoved(sbn);
mUiHandler.obtainMessage(MSG_NOTIFICATION_REMOVED,
toKeyPair(sbn)).sendToTarget();
@@ -158,6 +168,8 @@ public class NotificationListener extends NotificationListenerService {
} else {
activeNotifications = new ArrayList<>();
}
// Flexera Launcher: rebuild the preview texts from the same list as the dots.
app.flexera.drawer.FlexeraNotificationPreview.onFullRefresh(activeNotifications);
mUiHandler.obtainMessage(message.what, activeNotifications).sendToTarget();
return true;
@@ -102,9 +102,11 @@ public class PopupDataProvider implements NotificationListener.NotificationsChan
dotInfo = new DotInfo();
mPackageUserToDotInfos.put(postedPackageUserKey, dotInfo);
}
if (dotInfo.addOrUpdateNotificationKey(notificationKey)) {
updateNotificationDots(postedPackageUserKey::equals);
}
// Flexera Launcher: refresh the app's icons on every posted notification, not only when
// the dot or its count changes. A notification that is updated in place keeps its dot
// but brings new text for the drawer's preview line (PRD AD-11).
dotInfo.addOrUpdateNotificationKey(notificationKey);
updateNotificationDots(postedPackageUserKey::equals);
}
@Override