Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0959f36587 | ||
|
|
8aba905d9c | ||
|
|
60d00afcfd | ||
|
|
74becdd355 | ||
|
|
4771d2928a | ||
|
|
1f9116d666 | ||
|
|
4e48615b5a | ||
|
|
f92276da15 | ||
|
|
9d444ae2d0 | ||
|
|
d814125a66 | ||
|
|
c9744a1994 |
No files matched your search
@@ -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
@@ -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?)." }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
-28
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user