11 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
TMCandClaude Fable 5.2 f92276da15 Ask for the home role after the launcher has finished resuming; keep failed background checks quiet
First run (PRD SI-1)
- The request to become the default home app never appeared. Started from onResume, it
  was still pending when the launcher finished resuming, and Quickstep cancels any
  pending request at that point. It now starts from onDeferredResumed, after that
  check, through Launcher3's own ApiWrapper.assignDefaultHomeRole.

Updater
- An automatic check that fails (phone offline, say) no longer leaves "couldn't reach
  the update server" on the About screen. The screen keeps what it showed before, and
  the check runs again in an hour. A check started by hand still reports the failure.

Checked on the Android 17 emulator with the 0.2.0 release build made before this commit:
swipe up and hold in gesture and in three-button navigation, the list darkening under
a wave that reaches in from the right edge, and the work profile tabs. The two changes
in this commit are not yet checked.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 23:35:14 +01:00
TMCandClaude Fable 5.2 9d444ae2d0 Make releases compile from scratch and start-test them; ask to become the home app on first run
Release script
- The first 0.2.0 build crashed at start with NoSuchFieldError: $stable. Its Kotlin was
  compiled incrementally on top of the 0.1.0 output after a preference class had changed,
  the same fault seen earlier in debug builds. It was caught by hand before publishing.
- flexera/release.ps1 now deletes the release variant's Kotlin output and builds without
  the build cache, so a release never reuses earlier compiled code.
- New step: install the APK on the running emulator, start it, and stop without
  publishing if it crashes or is not running after 25 seconds. Installed copies update
  themselves, so a release that crashes at start must not get out. -NoSmokeTest skips
  the step on a machine without an emulator.

First run (PRD SI-1)
- The first time the launcher comes to the front while another app is the home app, it
  asks Android to offer it as the default home app (FlexeraFirstRun). Asked once; if
  declined, Launcher3's own hint and the reminder in settings remain.

Not yet checked on a device: the first-run prompt.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 23:22:26 +01:00
TMC d814125a66 Release 0.2.0 2026-11-11 23:10:24 +01:00
TMCandClaude Fable 5.2 c9744a1994 Add scrub from home, fix the work profile layout, and let swipe up and hold work with three buttons
Scrub from home (PRD AD-10)
- New setting, off by default: a vertical slide along the alphabet's edge of the home
  screen opens the drawer and scrubs it in the same touch (FlexeraEdgeScrubController).
  It claims a touch only once it has clearly moved up or down, and never during an item
  drag. Nothing is drawn on the home screen.

Work profile (PRD AD-14)
- The Personal and Work tabs and the work info card were one icon wide, because their
  width came from grid maths that assumes several columns. In the one-column list they
  now line up with the search bar.
- The info card's own scroller section no longer shows up as a letter in the alphabet.

Navigation modes (PRD SI-2, AD-17)
- With three-button navigation, the controller that carries swipe up and hold stood aside
  completely, so a hold did nothing there. It now takes the ordinary swipe up from the
  home screen in that mode too.

Wave alphabet
- The list darkens under the wave where the letters cross icons and names: always from
  the left edge, and from the right edge in proportion to how far the wave reaches in.

Release script
- -BuildOnly builds and checks the APK without publishing, so a version can be tried
  first and then published with -SkipBuild.

Checked on the Android 17 emulator (debug build): work tabs at full width, scrub from the
home screen's edge, the search field keeping its background while typing. The
three-button change and the right-edge darkening were written after that build and are
not yet checked.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 23:10:16 +01:00
25 changed files with 891 additions and 117 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
+87 -18
View File
@@ -5,17 +5,20 @@
.DESCRIPTION
One command per release. It
1. writes the new version to flexera/version.properties and commits it,
2. builds the signed release APK,
2. builds the signed release APK, compiling the Kotlin code from scratch,
3. checks that the APK is signed with the release key (not the debug key),
4. creates the release "v<version>" in https://git.mariosem.es/msemes/Flexera-Launcher-Releases
and attaches the APK, and
5. tags the source commit it was built from.
4. installs the APK on the running Android emulator, starts it and looks for a crash,
5. creates the release "v<version>" in https://git.mariosem.es/msemes/Flexera-Launcher-Releases,
6. attaches the APK to it, and
7. tags the source commit it was built from.
Installed copies of the app look at that repository once a day (app.flexera.update.FlexeraUpdater)
and offer the new version.
and offer the new version. A release that crashes at start would therefore reach the phone on
its own, which is why step 4 exists and why nothing is published when it fails.
Needs: keystore.properties and the keystore it names in the project root (both are git-ignored;
without them the script stops), and the git login for git.mariosem.es that git already has stored.
without them the script stops), the git login for git.mariosem.es that git already has stored,
and a running emulator for step 4 (start it with: emulator.exe -avd Flexera_Launcher_A17).
.PARAMETER Version
The new version, as major.minor.patch (for example 0.2.0). Must be higher than the last release.
@@ -26,8 +29,15 @@
.PARAMETER NotesFile
A text file with the notes, as an alternative to -Notes.
.PARAMETER BuildOnly
Stop after step 4, without publishing. Try the APK by hand, then run the script again with
-SkipBuild to publish that same file.
.PARAMETER SkipBuild
Publish the APK that is already in build\outputs instead of building again.
Use the APK that is already in build\outputs instead of building again.
.PARAMETER NoSmokeTest
Skip step 4. Only for a machine without an emulator; try the APK on a phone yourself first.
.PARAMETER AllowDirty
Release even though there are uncommitted changes. The APK then matches no commit exactly.
@@ -37,12 +47,18 @@
.EXAMPLE
.\flexera\release.ps1 -Version 0.2.0 -Notes "The alphabet follows the finger more closely."
.EXAMPLE
.\flexera\release.ps1 -Version 0.2.0 -BuildOnly
.\flexera\release.ps1 -Version 0.2.0 -SkipBuild -Notes "The alphabet follows the finger more closely."
#>
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$Notes = "",
[string]$NotesFile,
[switch]$BuildOnly,
[switch]$SkipBuild,
[switch]$NoSmokeTest,
[switch]$AllowDirty,
[switch]$PushSource
)
@@ -53,8 +69,13 @@ $Server = 'https://git.mariosem.es'
$GitHost = 'git.mariosem.es'
$ReleaseRepository = 'msemes/Flexera-Launcher-Releases'
$Api = "$Server/api/v1/repos/$ReleaseRepository"
$VariantDir = 'lawnWithQuickstepGithubRelease'
$Variant = 'LawnWithQuickstepGithubRelease'
$AppId = 'com.sema.flexeralauncher'
$HomeActivity = "$AppId/app.lawnchair.LawnchairLauncher"
$JavaHome = 'C:\Program Files\Android\Android Studio\jbr'
$Adb = Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe'
$SmokeTestSeconds = 25
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
@@ -89,7 +110,7 @@ function Read-Response($response, [string]$what) {
# --- 1. Version ----------------------------------------------------------------------------------
Step "1/6 Version $Version"
Step "1/7 Version $Version"
if ($Version -notmatch '^\d+\.\d{1,2}\.\d{1,2}$') {
throw "Version must look like 0.2.0 (major.minor.patch, minor and patch below 100), got '$Version'."
@@ -136,21 +157,30 @@ if ($current -ne $Version) {
$apk = Join-Path $Root "build\outputs\apk\lawnWithQuickstepGithub\release\FlexeraLauncher.$Version.github.release.apk"
if ($SkipBuild) {
Step "2/6 Build skipped"
Step "2/7 Build skipped"
} else {
Step "2/6 Building the release APK (this takes a while)"
Step "2/7 Building the release APK (about 15 minutes)"
if (-not (Test-Path (Join-Path $Root 'keystore.properties'))) {
throw "keystore.properties is missing, so the APK would be signed with the debug key and could not update an installed copy."
}
# A release compiles its Kotlin code from scratch. Compiled output reused from an earlier
# build has produced an APK that crashed at start after a preference class had changed
# (NoSuchFieldError: $stable), so nothing compiled earlier is trusted here.
Remove-Item -Recurse -Force (Join-Path $Root "build\intermediates\built_in_kotlinc\$VariantDir") -ErrorAction SilentlyContinue
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 --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." }
# --- 3. Signature --------------------------------------------------------------------------------
Step "3/6 Checking the signature"
Step "3/7 Checking the signature"
$apksigner = Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Android\Sdk\build-tools') -Recurse -Filter 'apksigner.bat' |
Sort-Object FullName | Select-Object -Last 1
@@ -163,17 +193,56 @@ if ($signer -match 'Android Debug') { throw "The APK is signed with the debug ke
Write-Host $signer
Write-Host ("APK: {0} ({1:N1} MB)" -f (Split-Path $apk -Leaf), ((Get-Item $apk).Length / 1MB))
# --- 4. Release ----------------------------------------------------------------------------------
# --- 4. Start it once ----------------------------------------------------------------------------
Step "4/6 Creating release v$Version in $ReleaseRepository"
if ($NoSmokeTest) {
Step "4/7 Start-up check skipped"
Write-Warning "This APK has not been started anywhere. Try it on a phone before publishing."
} else {
Step "4/7 Starting the APK on the emulator"
$emulator = & $Adb devices | Where-Object { $_ -match '^(emulator-\d+)\s+device$' } |
ForEach-Object { $Matches[1] } | Select-Object -First 1
if (-not $emulator) {
throw "No running emulator found. Start one (emulator.exe -avd Flexera_Launcher_A17) and run this again with -SkipBuild, or pass -NoSmokeTest."
}
$install = & $Adb -s $emulator install -r $apk
if ($LASTEXITCODE -ne 0 -or -not ($install -match 'Success')) {
throw "Could not install the APK on $emulator. If an older build signed with another key is installed there, uninstall it first: adb uninstall $AppId"
}
& $Adb -s $emulator logcat -b crash -c
& $Adb -s $emulator shell am start -n $HomeActivity | Out-Null
Write-Host "Installed on $emulator and started; watching for $SmokeTestSeconds seconds..."
Start-Sleep -Seconds $SmokeTestSeconds
$crashes = & $Adb -s $emulator logcat -b crash -d
$ours = $crashes | Where-Object { $_ -match "Process: $([regex]::Escape($AppId))(,|\s|$)" }
$alive = & $Adb -s $emulator shell pidof $AppId
if ($ours -or -not $alive) {
$crashes | Select-Object -First 12 | ForEach-Object { Write-Host $_ }
throw "The app crashed or is not running after start. Nothing was published."
}
Write-Host "No crash, and the app is running."
}
if ($BuildOnly) {
Write-Host "`nBuilt and checked, not published: $apk" -ForegroundColor Green
Write-Host "Publish this file with: .\flexera\release.ps1 -Version $Version -SkipBuild -Notes `"...`""
return
}
# --- 5. Release ----------------------------------------------------------------------------------
Step "5/7 Creating release v$Version in $ReleaseRepository"
$body = @{ tag_name = "v$Version"; name = "Flexera Launcher $Version"; body = $Notes; draft = $false; prerelease = $false } | ConvertTo-Json
$content = New-Object System.Net.Http.StringContent($body, [Text.Encoding]::UTF8, 'application/json')
$release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) 'Creating the release'
# --- 5. APK --------------------------------------------------------------------------------------
# --- 6. APK --------------------------------------------------------------------------------------
Step "5/6 Uploading the APK"
Step "6/7 Uploading the APK"
$assetName = "FlexeraLauncher-$Version.apk"
$stream = [IO.File]::OpenRead($apk)
@@ -193,9 +262,9 @@ try {
}
Write-Host "Uploaded: $($asset.browser_download_url)"
# --- 6. Source tag -------------------------------------------------------------------------------
# --- 7. Source tag -------------------------------------------------------------------------------
Step "6/6 Tagging the source"
Step "7/7 Tagging the source"
git tag -a "v$Version" -m "Flexera Launcher $Version"
if ($LASTEXITCODE -ne 0) { Write-Warning "Could not create the tag v$Version (does it exist already?)." }
+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>
+7
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,9 +43,13 @@
<!-- 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>
<string name="flexera_scrub_from_home">Scrub from the home screen</string>
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
<!-- Updates section of the About screen (app.flexera.update.FlexeraUpdater) -->
<string name="flexera_update_heading">Updates</string>
@@ -0,0 +1,48 @@
package app.flexera
import android.app.Activity
import android.app.role.RoleManager
import android.content.Context
import android.os.Build
import android.util.Log
import com.android.launcher3.util.ApiWrapper
/**
* First-run step (PRD SI-1): the first time the launcher is opened while another app is the
* phone's home app, ask Android to offer Flexera Launcher as the default.
*
* Android shows its own dialog for this. It is asked once only. If the owner declines, Launcher3
* shows a short hint about Android's settings, and the launcher's own settings keep a reminder
* that leads to the same choice, so there is no need to ask again.
*/
object FlexeraFirstRun {
private const val TAG = "FlexeraFirstRun"
private const val PREFS = "flexera_first_run"
private const val KEY_ASKED_DEFAULT_HOME = "asked_default_home"
/**
* Call once the launcher has finished resuming (its onDeferredResumed), not earlier: the
* launcher cancels any request for a result that is pending at that point. Does nothing
* after the first time.
*/
@JvmStatic
fun askToBecomeDefaultHome(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
val prefs = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_ASKED_DEFAULT_HOME, false)) return
val roles = activity.getSystemService(RoleManager::class.java) ?: return
if (!roles.isRoleAvailable(RoleManager.ROLE_HOME)) return
prefs.edit().putBoolean(KEY_ASKED_DEFAULT_HOME, true).apply()
if (roles.isRoleHeld(RoleManager.ROLE_HOME)) return
try {
// Launcher3's own way of asking: Android's consent dialog, then a short hint about
// Android's settings if the owner declines.
ApiWrapper.INSTANCE.get(activity).assignDefaultHomeRole(activity)
} catch (e: Exception) {
Log.w(TAG, "Could not ask to become the default home app", e)
}
}
}
@@ -2,6 +2,7 @@ package app.flexera.drawer
import android.content.Context
import android.graphics.Color
import android.graphics.Point
import android.graphics.Rect
import android.os.Build
import android.util.TypedValue
@@ -16,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
@@ -86,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.
@@ -104,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.
@@ -196,21 +217,73 @@ 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 bounds = Rect()
dragLayer.getDescendantRectRelativeToSelf(wave, bounds)
val x = ev.x - bounds.left
val y = ev.y - bounds.top
if (wave.isScrubbing) wave.moveScrub(x, y) else wave.beginScrub(x, y)
val offset = layoutOffset(wave, dragLayer)
if (wave.isScrubbing) wave.moveScrub(x - offset.x, y - offset.y) else wave.beginScrub(x - offset.x, y - offset.y)
}
/**
* True when [ev], given in the drag layer's coordinates, lands where the wave alphabet's
* strip will be once the drawer is open. Scrub from home (PRD AD-10) starts there.
*/
@JvmStatic
fun isOnHomeEdge(
appsView: ActivityAllAppsContainerView<*>,
dragLayer: BaseDragLayer<*>,
ev: MotionEvent,
): Boolean {
val wave = waveAlphabet(appsView) ?: return false
val offset = layoutOffset(wave, dragLayer)
return wave.isOnStripBand(ev.x - offset.x, ev.y - offset.y)
}
/**
* Where [view] sits inside [ancestor] going by layout alone. A closed drawer is parked
* off-screen with a translation; this ignores it, giving the place the view has when the
* drawer is open.
*/
private fun layoutOffset(view: View, ancestor: View): Point {
val offset = Point()
var current: View? = view
while (current != null && current !== ancestor) {
offset.x += current.left
offset.y += current.top
current = current.parent as? View
}
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.
@@ -0,0 +1,117 @@
package app.flexera.drawer
import android.os.SystemClock
import android.view.MotionEvent
import android.view.ViewConfiguration
import app.lawnchair.preferences.PreferenceManager
import com.android.launcher3.AbstractFloatingView
import com.android.launcher3.Launcher
import com.android.launcher3.LauncherState
import com.android.launcher3.Utilities
import com.android.launcher3.util.TouchController
import kotlin.math.abs
/**
* Scrub from home (PRD AD-10). With the setting on, a vertical slide that starts on the home
* screen's edge, where the drawer's alphabet sits, opens the drawer and scrubs it in the same
* 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. 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 {
private val touchSlop = ViewConfiguration.get(launcher).scaledTouchSlop
private val prefs = PreferenceManager.getInstance(launcher)
/** 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
override fun onControllerInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
scrubbing = false
candidate = canStart(ev)
downX = ev.x
downY = ev.y
}
MotionEvent.ACTION_MOVE -> if (candidate) {
val dx = abs(ev.x - downX)
val dy = abs(ev.y - downY)
when {
// A long press picked something up, or the finger went sideways: not ours.
launcher.dragController.isDragging || (dx > touchSlop && dx >= dy) -> candidate = false
dy > touchSlop && dy > dx * VERTICAL_BIAS -> {
candidate = false
scrubbing = true
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
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> candidate = false
}
return false
}
override fun onControllerTouchEvent(ev: MotionEvent): Boolean {
if (!scrubbing) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE ->
FlexeraDrawer.scrubFromSwipe(launcher.appsView, launcher.dragLayer, ev)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
scrubbing = false
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 &&
launcher.isInState(LauncherState.NORMAL) &&
AbstractFloatingView.getTopOpenView(launcher) == null &&
FlexeraDrawer.isOnHomeEdge(launcher.appsView, launcher.dragLayer, ev)
override fun dump(): String = "FlexeraEdgeScrubController candidate=$candidate scrubbing=$scrubbing"
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,11 +71,16 @@ 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. */
private val workInfoSection = res.getString(R.string.work_profile_edu_section)
// The drawer has no background, so the letters are white like the app names: slightly
// dimmed at rest, full white near the finger.
private val idleColor = ColorUtils.setAlphaComponent(Color.WHITE, 0xB3)
@@ -88,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
@@ -129,8 +160,9 @@ class WaveAlphabetView(
for (section in sections) {
val label = section.sectionName?.toString().orEmpty()
// Launcher3 repeats the last section once more so its own scroller can reach the
// bottom, and Private Space uses a long badge string; neither is a letter.
if (label.isEmpty() || label.length > 2) continue
// bottom, Private Space uses a long badge string, and the work profile's info card
// has a section of its own; none of these is a letter.
if (label.isEmpty() || label.length > 2 || label == workInfoSection) continue
if (result.isNotEmpty() && result.last().label == label) continue
result.add(Letter(label, section.position))
}
@@ -172,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)
@@ -218,27 +256,39 @@ class WaveAlphabetView(
if (layoutDirty) computeLayout()
if (letters.isEmpty()) return
// On the left edge the wave reaches out over the icons and names, so darken the list
// under it for as long as the finger is down. On the right it crosses empty space.
if (onLeft && press > 0f) {
canvas.drawColor(Color.argb((LEFT_SCRUB_DIM * press * 255).toInt(), 0, 0, 0))
}
// 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 reach = (fingerDistance + fingerGap).coerceIn(minReach, width * 0.7f) * press
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 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 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
@@ -251,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)
@@ -258,9 +322,16 @@ class WaveAlphabetView(
// ---- Touch -------------------------------------------------------------------------------
/** True when a touch at this point, in this view's coordinates, starts a scrub. */
fun isOnStrip(x: Float, y: Float): Boolean {
fun isOnStrip(x: Float, y: Float): Boolean =
appsView.isInAllApps && !appsView.isSearching && isOnStripBand(x, y)
/**
* True when this point lies on the band along the edge that the letters occupy, whether or
* not the drawer is open. Scrub from home (PRD AD-10) asks this while the drawer is closed.
*/
fun isOnStripBand(x: Float, y: Float): Boolean {
if (layoutDirty) computeLayout()
if (letters.isEmpty() || !appsView.isInAllApps || appsView.isSearching) return false
if (letters.isEmpty()) return false
val onEdge = if (onLeft) x <= touchWidth else x >= width - touchWidth
return onEdge && y >= stripTop - verticalMargin && y <= stripBottom + verticalMargin
}
@@ -300,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
@@ -443,14 +535,25 @@ 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
/** How dark the list gets under a scrub from the left edge (0 = unchanged, 1 = black). */
const val LEFT_SCRUB_DIM = 0.55f
/** 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),
@@ -61,6 +66,11 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
description = stringResource(id = R.string.flexera_alphabet_on_left_description),
adapter = prefs.flexeraAlphabetOnLeft.getAdapter(),
)
SwitchPreference(
label = stringResource(id = R.string.flexera_scrub_from_home),
description = stringResource(id = R.string.flexera_scrub_from_home_description),
adapter = prefs.flexeraScrubFromHome.getAdapter(),
)
}
// No background colour or opacity here: the drawer is transparent with white text, and a
// light panel behind white text would make the list unreadable.
@@ -188,8 +188,9 @@ object FlexeraUpdater {
val release = result.getOrElse { error ->
Log.w(TAG, "Update check failed", error)
// A failed background check must not wipe out an update that is already waiting.
mutableState.value = if (automatic && before.holdsRelease()) before else UpdateState.Failed(UpdateFailure.CHECK)
// A background check that fails stays quiet: the About screen keeps what it showed
// before, including an update that is already waiting. It tries again in an hour.
mutableState.value = if (automatic) before else UpdateState.Failed(UpdateFailure.CHECK)
return@launch
}
@@ -301,9 +302,6 @@ object FlexeraUpdater {
private fun isBusy(): Boolean =
mutableState.value.let { it is UpdateState.Checking || it is UpdateState.Downloading }
private fun UpdateState.holdsRelease(): Boolean =
this is UpdateState.Available || this is UpdateState.ReadyToInstall
/** Returns the latest published release, or null when nothing has been published yet. */
private fun fetchLatestRelease(context: Context): FlexeraRelease? {
val connection = open(context, LATEST_RELEASE_URL)
+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.1.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"
@@ -308,7 +308,9 @@ class LawnchairLauncher : QuickstepLauncher() {
override fun createTouchControllers(): Array<TouchController> {
val verticalSwipeController = VerticalSwipeTouchController(this, gestureController)
return arrayOf<TouchController>(verticalSwipeController) + super.createTouchControllers()
// Flexera Launcher: scrub from home (PRD AD-10) gets the first look at touches on the alphabet's edge.
val edgeScrubController = app.flexera.drawer.FlexeraEdgeScrubController(this)
return arrayOf<TouchController>(edgeScrubController, verticalSwipeController) + super.createTouchControllers()
}
override fun handleHomeTap() {
@@ -467,6 +469,14 @@ class LawnchairLauncher : QuickstepLauncher() {
return ActivityOptionsWrapper(options, callback)
}
override fun onDeferredResumed() {
super.onDeferredResumed()
// Flexera Launcher: on first run, ask to become the default home app (PRD SI-1). It has
// to wait until here. Quickstep cancels any request for a result that is already pending
// when the launcher finishes resuming, which is what the call above does.
app.flexera.FlexeraFirstRun.askToBecomeDefaultHome(this)
}
override fun onResume() {
super.onResume()
restartIfPending()
@@ -217,6 +217,13 @@ class PreferenceManager @Inject constructor(
// Flexera Launcher: which screen edge the drawer's wave alphabet sits on (PRD AD-15).
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).
// 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.
@@ -117,11 +132,17 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
protected boolean canInterceptTouch(MotionEvent ev) {
mIsTrackpadSwipe = isTrackpadMotionEvent(ev);
mLauncher.setCanShowAllAppsEducationView(!mIsTrackpadSwipe);
if (!mIsTrackpadSwipe && DisplayController.getNavigationMode(mLauncher)
== THREE_BUTTONS) {
boolean threeButtons = !mIsTrackpadSwipe
&& DisplayController.getNavigationMode(mLauncher) == THREE_BUTTONS;
// Flexera Launcher: with three-button navigation this controller used to stand aside
// completely. It now still takes the ordinary swipe up from the home screen, so that
// swipe up and hold (PRD AD-17) works in that mode too. Everything else is left to the
// controllers after it, as before.
if (threeButtons && !mLauncher.isInState(NORMAL)) {
return false;
}
mDidTouchStartInNavBar = (ev.getEdgeFlags() & EDGE_NAV_BAR) != 0;
// The buttons own the navigation bar, so a touch never counts as starting on it.
mDidTouchStartInNavBar = !threeButtons && (ev.getEdgeFlags() & EDGE_NAV_BAR) != 0;
boolean isOneHandedModeActive = (SystemUiProxy.INSTANCE.get(mLauncher)
.getLastSystemUiStateFlags() & SYSUI_STATE_ONE_HANDED_ACTIVE) != 0;
// Reset touch slop multiplier to default 1.0f if one-handed-mode is not active
@@ -171,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) {
@@ -213,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) {
@@ -282,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);
}
@@ -320,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
@@ -121,6 +121,12 @@ public class PersonalWorkSlidingTabStrip extends LinearLayout implements PageInd
*/
public static int getTabWidth(Context context, int totalWidth) {
DeviceProfile grid = ActivityContext.lookupContext(context).getDeviceProfile();
// Flexera Launcher: the one-column drawer list has no left and right icon to span. The
// grid maths below would make the tabs and the work info card one icon wide, so line
// them up with the search bar instead.
if (grid.numShownAllAppsColumns == app.flexera.drawer.FlexeraDrawer.COLUMNS) {
return totalWidth - 2 * app.flexera.drawer.FlexeraDrawer.rowStartPadding(context);
}
int iconPadding = totalWidth / grid.numShownAllAppsColumns
- grid.getAllAppsProfile().getIconSizePx();
return totalWidth - iconPadding;