6 Commits
Author SHA1 Message Date
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
18 changed files with 498 additions and 49 deletions

No files matched your search

+84 -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,27 @@ 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"
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain"
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 +190,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 +259,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?)." }
+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>
+4
View File
@@ -40,9 +40,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
@@ -86,6 +87,9 @@ object FlexeraDrawer {
@JvmStatic
fun styleAppRow(icon: BubbleTextView) {
val res = icon.resources
// 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)
icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height)
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))
@@ -198,13 +202,43 @@ object FlexeraDrawer {
ev: MotionEvent,
) {
val wave = waveAlphabet(appsView) ?: return
val bounds = Rect()
dragLayer.getDescendantRectRelativeToSelf(wave, bounds)
val x = ev.x - bounds.left
val y = ev.y - bounds.top
val offset = layoutOffset(wave, dragLayer)
val x = ev.x - offset.x
val y = ev.y - offset.y
if (wave.isScrubbing) wave.moveScrub(x, y) else wave.beginScrub(x, y)
}
/**
* 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
}
/** Ends a scrub started by [scrubFromSwipe]; the drawer stays open at the chosen letter. */
@JvmStatic
fun endScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
@@ -0,0 +1,90 @@
package app.flexera.drawer
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. It is off by default.
*/
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 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
launcher.stateManager.goToState(LauncherState.ALL_APPS)
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
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
}
}
return true
}
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
}
}
@@ -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()
}
@@ -71,6 +71,9 @@ class WaveAlphabetView(
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
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)
@@ -129,8 +132,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))
}
@@ -218,19 +222,24 @@ 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.
val fingerDistance = (fingerX - baseX) * inward
val reach = (fingerDistance + fingerGap).coerceIn(minReach, width * 0.7f) * press
val maxReach = width * 0.7f
val fullReach = (fingerDistance + fingerGap).coerceIn(minReach, maxReach)
val reach = fullReach * press
// Darken the list under the wave where its letters cross icons and names. From the
// right edge that only happens once the wave reaches well into the screen, as it does
// after swipe up and hold; from the left edge the icons sit right beside the strip.
if (press > 0f) {
val overList = if (onLeft) 1f else ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
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]
@@ -258,9 +267,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
}
@@ -449,8 +465,8 @@ class WaveAlphabetView(
/** How much its neighbours grow, tapering with distance from the finger. */
const val NEAR_GROWTH = 0.5f
/** How dark the list gets under a scrub from the left edge (0 = unchanged, 1 = black). */
const val LEFT_SCRUB_DIM = 0.55f
/** How dark the list gets under a wave that lies fully over it (0 = unchanged, 1 = black). */
const val SCRUB_DIM = 0.55f
/** 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
@@ -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.3.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,12 @@ 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).
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false)
// 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")
}
@@ -117,11 +117,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
@@ -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;