23 Commits
Author SHA1 Message Date
TMCandClaude Fable 5.2 04e78205dd Release script: do not take Android's own crash during an APK swap for the app's
When the release build is the emulator's home app, Android restarts it while
the files are still being replaced, and that start can fail inside
ActivityThread.handleBindApplication. The start-up check now waits for the swap
to settle, stops the app, and judges a fresh start.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:57:49 +01:00
TMC cef6929a41 Release 0.9.0 2026-11-12 14:36:21 +01:00
TMCandClaude Fable 5.2 fba65e091f Add quick launch: a swipe past the first or last home page opens a chosen app
Swiping on from the first home screen page, towards the page that is not
there, now does what the owner chose under Settings, Gestures, Quick launch:
open an app, or any other of the launcher's gesture actions. The last page has
a choice of its own.

The stretch the pages already show at either end is an edge effect; quick
launch replaces it with one that also counts how far the finger has pulled.
Lifting the finger after a pull of about a seventh of the screen, or after a
flick, does the action; a short pull or one that is taken back does nothing.
An app opens out of the screen edge the finger pulled from.

From Android 12, where the stretch is Android's own. Infinite scrolling takes
those swipes for itself, so the two do not combine.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:36:03 +01:00
TMC b696a376b2 Release 0.8.1 2026-11-12 13:52:28 +01:00
TMCandClaude Fable 5.2 bfc2a0ea36 Ask for local network access when the update server is on the phone's own network
On Android 17 an app that targets it may not connect to devices on the local
network without the new ACCESS_LOCAL_NETWORK permission; a blocked connection is
dropped and ends as a timeout. At the owner's home the update server's name
leads to its address on the home network, so the check for updates failed there
with "did not answer" while the same server answered every other device.

The updater now recognises this case before it connects (Android 17, permission
not granted, server address inside one of the phone's own network ranges) and
says so at once. A tap on that message asks for the permission and runs the
check again; a refusal that Android no longer asks about leads to the app's
page in the phone's settings.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:52:16 +01:00
TMC fd1c8b195e Release 0.8.0 2026-11-12 13:04:44 +01:00
TMCandClaude Fable 5.2 5c4a6c346d Add the Pictures widget and carry Smart Launcher's photo widget over
Pictures is a widget that comes with the launcher: one picture, or several that
take turns every 10 seconds with a cross-fade (a tap shows the next at once).
Android knows it as an ordinary app widget, so it is added, moved, resized and
removed like any other, but the launcher draws it itself. That lets its frame
sit exactly on the outer edges of the icons of the cells it covers and take the
corners of the icon shape in use.

Pictures are chosen with Android's photo picker and copied, scaled down, into
the launcher's own storage, one folder per widget, so no gallery permission is
needed. The widget's edit button opens the picker again; removing the widget
deletes its copies.

Launcher3's widget host view shows the launcher's own view for this provider
and skips the views Android would send, and the cell container gives the widget
its cells without the usual inset.

The Smart Launcher import now turns that launcher's photo widget into a
Pictures widget in the same place. Its photo is taken from the backup and
handed to the widget when the owner taps it to set it up.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:04:34 +01:00
TMCandClaude Fable 5.2 d5a62cc6f5 Redraw every open copy of the launcher when a setting changes
Opening the launcher like an app (the installer's "Open") and then making it
the home app leaves two launcher activities in one process. A changed setting
recreated whichever one LawnchairApp.launcher returned, which could be the one
in the background, so the home screen kept its old look until a restart.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:04:33 +01:00
TMC 8cfa2fdc84 Release 0.7.0 2026-11-12 12:09:20 +01:00
TMCandClaude Fable 5.2 d0d1c031d0 Add a row size setting and give the screen edge back to Android's Back swipe
Row size (Settings, App drawer, Style): Compact is the list as it was, Comfy
has taller rows, larger icons and slightly larger names and letter headers.

In the open drawer the alphabet no longer reserves the screen edge. Its letters
rest 30dp from the edge, clear of the strip where Android starts Back. A touch
that starts on that strip becomes a scrub once it moves up or down or rests for
a moment; a sideways swipe is left to Android and goes back. The band that
starts a scrub from the home screen is narrower than the one in the drawer.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 12:09:11 +01:00
TMC 494364ffa2 Release 0.6.1 2026-11-12 10:43:09 +01:00
TMCandClaude Fable 5.2 80a56c01c9 Say why a check for updates failed
The owner's phone answered a manual check with "Couldn't reach the update server" while
the same published version found the update on the emulator and the server answered from
the PC. The message gave no way to tell a missing network permission from a VPN, a name
that does not resolve, or a server error.

- A failed check now carries what kind of failure it was (no network permission, server
  not found, no answer, no secure connection, server error, unreadable answer) and the
  error's own words. The About screen says the kind in plain words with what to try, and
  shows the words under it as "Details".
- The no-permission case is told apart before any request is made; some Android systems
  let the owner switch the network off per app.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:43:07 +01:00
TMC 043e1e0c4a Release 0.6.0 2026-11-12 10:13:49 +01:00
TMCandClaude Fable 5.2 d82d488189 Import home screens from a Smart Launcher backup; survive a grid reload with unset widgets
Asked for by the owner on 12 November 2026, with one of their own backups to work from.

Import (Settings > Backup and restore > Import from Smart Launcher)
- A .slbk file is a zip of Smart Launcher's files. The importer reads its database
  (HomeItem, Launchable, Action, Widget) and the grid from its preferences, and writes the
  home screens as a Launcher3 database that Launcher3's restore code takes in, the way
  Lawnchair's Nova import does. The format is not documented; this follows one real
  backup of data version 64 and refuses a file without those tables.
- One to one, by the owner's decision: every icon, folder and widget in the cell it had, on
  a grid of the same size, and the dock switched off because Smart Launcher has none.
  Home screen labels follow the backup.
- Widgets arrive as "tap to set up". Android has each launcher ask for its own widgets and
  keeps a widget's settings with the launcher that made it.
- Left out, and said so on the screen before the owner agrees: Smart Launcher's own widgets,
  items of other profiles, anything without a usable target or free cells. Icons of apps
  that are not installed are dropped by Launcher3 when it loads the layout.
- Settings are changed only after Launcher3 has accepted the layout, so a failed import
  changes nothing.
- Entry names in the zip are not trusted: only the database and the preference files are
  unpacked, each under a fixed name, with a size limit.

Fix in Launcher3 (WidgetSizeHandler)
- A widget waiting for "tap to set up" has an id Android has reserved but not bound.
  Android 17 answers a size update for such an id with a NullPointerException, which ended
  the launcher whenever the grid reloaded with such a widget on a home screen. Found
  because the import first changed a setting that reloads the grid while the previous
  layout still had unset widgets. The answer is now logged and dropped.

Checked on the Android 17 emulator: the owner's backup itself (two apps of it exist there
and land in their cells), a copy of its layout pointed at apps the emulator has (all 7
icons, 6 folders and 4 widgets in place, one widget set up through Android's dialog), and
two grid reloads with unset widgets on screen.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:13:41 +01:00
TMC 8894271daa Release 0.5.0 2026-11-12 09:25:56 +01:00
TMCandClaude Fable 5.2 3cb262617c Search field scrolls with the list; magnifier on the alphabet; a chosen letter's group goes to the middle
Three more requests from the owner on 12 November 2026.

Search field
- The field scrolls away with the list and comes back at the list's top, so the list has
  the whole height once it is scrolled. The field is not turned into a list row. The list
  now starts at the top of the drawer instead of under the field and keeps the field's room
  as top padding (ActivityAllAppsContainerView.replaceAppsRVContainer and
  AdapterHolder.applyPadding); FlexeraDrawer.moveSearchFieldWithList shifts the field by as
  much as the list has scrolled, measured from the first row, and cuts it at the same line
  the rows are cut at, the lower edge of the status bar.
- With work tabs, a hidden or a floating search field, and during a search, nothing changes.

Alphabet
- A magnifier above the letters stands for the search field. Scrubbing to it jumps the list
  to its top; the bubble shows the magnifier. The stretch of edge kept for the strip grows
  by one place for it.
- The strip measures its room from where the list's rows start, not from the list's top.

Chosen letter
- A scrub brings the letter's group to the middle of the screen, where the thumb is,
  instead of to the top: the group's middle on the list's middle, a long group's header no
  higher than 22% of the height. At either end of the list the list stops at its end, so
  the first groups stay at the top and the last ones rest at the bottom.

Checked on the Android 17 emulator: scrolling by hand, scrubs to a middle letter, the last
letter and the magnifier, typing a search and leaving it, reopening after a scrub, the hold
and edge gestures, and a row's long-press menu.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 09:25:48 +01:00
TMC 0959f36587 Release 0.4.0 2026-11-12 09:00:30 +01:00
TMCandClaude Fable 5.2 8aba905d9c Thumb-friendly drawer: compact list, Niagara-style alphabet, dark backdrop; QR code on About
Requests from the owner on 12 November 2026, after using 0.3.1 on the Pixel 9a, with a
photo of Niagara Launcher's alphabet as the reference.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+2
View File
@@ -494,6 +494,8 @@ dependencies {
implementation libs.kdrag0n.colorkt
implementation libs.coil.compose
implementation libs.xdrop.fuzzywuzzy
// Flexera Launcher: encodes the QR code on the About screen. Nothing else of it is used.
implementation libs.zxing.core
implementation libs.bundles.opto
implementation libs.androidx.datastore.preferences
+34
View File
@@ -12,4 +12,38 @@
<!-- Lets the app hand a downloaded update to Android's installer. The owner still confirms each install. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
Since Android 17 an app needs this to reach a device on the network the phone itself is on.
The update server can be such a device: at the owner's home its name leads to its address
on the home network. Asked for only when a check for updates is stopped by it (About screen).
-->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application>
<!--
The picture widget that comes with the launcher. Not exported: only this launcher can
place it, and only this launcher knows how to draw it.
-->
<receiver
android:name="app.flexera.widget.PictureWidgetProvider"
android:exported="false"
android:label="@string/flexera_picture_widget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/flexera_picture_widget_info" />
</receiver>
<!-- Opens Android's photo picker for the picture widget; it has no screen of its own. -->
<activity
android:name="app.flexera.widget.PictureWidgetConfigActivity"
android:excludeFromRecents="true"
android:exported="false"
android:theme="@style/Theme.Transparent" />
</application>
</manifest>
+11 -1
View File
@@ -170,7 +170,10 @@ if ($SkipBuild) {
Get-ChildItem (Join-Path $Root 'build\kotlin') -Directory -Filter "compile${Variant}Kotlin*" -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force
$env:JAVA_HOME = $JavaHome
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain"
# "2>&1" inside cmd: Gradle prints compiler warnings on its error stream. If this script's
# output is piped or sent to a file, PowerShell would otherwise take the first such line
# for an error and stop the build. Only the exit code says whether the build failed.
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain 2>&1"
if ($LASTEXITCODE -ne 0) { throw "The build failed." }
}
if (-not (Test-Path $apk)) { throw "Expected the APK at $apk, but it is not there." }
@@ -208,6 +211,13 @@ if ($NoSmokeTest) {
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"
}
# When this app is the emulator's home app, Android restarts it the moment the old copy is
# gone, and that first start can die inside Android itself while the files are still being
# swapped (a NullPointerException in ActivityThread.handleBindApplication, with no line of
# this app in it). That is not the new build crashing: let the swap settle, then start
# from a clean slate and judge what happens from there.
Start-Sleep -Seconds 6
& $Adb -s $emulator shell am force-stop $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..."
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A sun over a hill on a rounded plate: the stand-in for the picture widget's content. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="120dp"
android:height="120dp"
android:viewportWidth="120"
android:viewportHeight="120">
<path
android:fillColor="#FF3D4A70"
android:pathData="M28,0h64a28,28 0,0 1,28 28v64a28,28 0,0 1,-28 28h-64a28,28 0,0 1,-28 -28v-64a28,28 0,0 1,28 -28z" />
<path
android:fillColor="#99FFFFFF"
android:pathData="M84,36m-12,0a12,12 0,1 1,24 0a12,12 0,1 1,-24 0" />
<path
android:fillColor="#40FFFFFF"
android:pathData="M0,120L0,104Q40,52 92,120Z" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
What Android is told the picture widget looks like before it has anything to show. Flexera
Launcher never shows it: it draws the widget itself (app.flexera.widget.PictureWidgetView).
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
android:src="@drawable/flexera_picture_widget_sample" />
</FrameLayout>
+65 -22
View File
@@ -1,11 +1,19 @@
<?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>
<!-- The "comfy" row size the owner can choose instead (Settings, App drawer): a little
larger all round, between compact and the first version. -->
<dimen name="flexera_drawer_row_height_comfy">58dp</dimen>
<dimen name="flexera_drawer_row_icon_size_comfy">40dp</dimen>
<dimen name="flexera_drawer_row_icon_gap_comfy">18dp</dimen>
<dimen name="flexera_drawer_row_text_size_comfy">16sp</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
@@ -13,29 +21,64 @@
<dimen name="flexera_drawer_row_padding_end">56dp</dimen>
<!-- Wave alphabet (PRD AD-4 to AD-6). -->
<!-- How wide a band along the edge starts a scrub when touched. -->
<dimen name="flexera_wave_touch_width">40dp</dimen>
<!-- Where the letters rest, measured from the edge. -->
<dimen name="flexera_wave_edge_inset">16dp</dimen>
<!-- How wide a band along the edge starts a scrub when touched in the open drawer. -->
<dimen name="flexera_wave_touch_width">60dp</dimen>
<!-- The same band on the home screen, where nothing shows it: narrower, so that fewer
swipes that only pass near the edge are taken for a scrub (PRD AD-10). -->
<dimen name="flexera_wave_home_touch_width">40dp</dimen>
<!-- Where the letters rest, measured from the edge. Inward of the strip of edge where
Android starts its Back swipe (about 24dp), so a thumb on the letters is not on it.
(First version: 16dp, with the whole edge reserved, which left no Back swipe.) -->
<dimen name="flexera_wave_edge_inset">30dp</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>
<!-- Picture widget: the words on a widget that has no pictures, and the room beside them. -->
<dimen name="flexera_picture_widget_hint_text_size">13sp</dimen>
<dimen name="flexera_picture_widget_hint_margin">10dp</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>
<dimen name="flexera_drawer_header_height_comfy">38dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom_comfy">5dp</dimen>
<dimen name="flexera_drawer_header_text_size_comfy">13sp</dimen>
</resources>
+4
View File
@@ -2,4 +2,8 @@
<resources>
<!-- The wave alphabet view inside the drawer (app.flexera.drawer.WaveAlphabetView). -->
<item name="flexera_wave_alphabet" type="id" />
<!-- View tag that marks an app row of the drawer list (FlexeraDrawer.styleAppRow). -->
<item name="flexera_drawer_row" type="id" />
<!-- View tag on a drawer row: set while the row shows a notification preview under the app name. -->
<item name="flexera_notification_preview" type="id" />
</resources>
+72 -1
View File
@@ -27,6 +27,46 @@
<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>
<!-- Import of a Smart Launcher backup (app.flexera.backup) -->
<string name="flexera_import_smart_launcher">Import from Smart Launcher</string>
<string name="flexera_import_smart_launcher_description">Home screens, folders and widgets from a Smart Launcher backup file (.slbk)</string>
<string name="flexera_import_found">In this backup</string>
<string name="flexera_import_screens">Home screens</string>
<string name="flexera_import_screens_value">%1$d, on a grid of %2$d by %3$d</string>
<string name="flexera_import_apps">Apps on the home screens</string>
<string name="flexera_import_folders">Folders</string>
<plurals name="flexera_import_folders_value">
<item quantity="one">%1$d, holding %2$d app</item>
<item quantity="other">%1$d, holding %2$d apps</item>
</plurals>
<string name="flexera_import_widgets">Widgets</string>
<string name="flexera_import_widgets_value">%1$d. Each arrives in its place as “tap to set up”: Android asks you to allow it again, and its own settings start fresh.</string>
<string name="flexera_import_photo_widgets">Photo widgets</string>
<plurals name="flexera_import_photo_widgets_value">
<item quantity="one">%1$d. It becomes Flexera Launcher\'s picture widget and keeps its photo. Tap it once after the import.</item>
<item quantity="other">%1$d. They become Flexera Launcher\'s picture widgets and keep their photos. Tap each once after the import.</item>
</plurals>
<string name="flexera_import_left_out">Left out</string>
<string name="flexera_import_own_widgets">Other widgets of Smart Launcher itself</string>
<plurals name="flexera_import_own_widgets_value">
<item quantity="one">%1$d. It exists only inside Smart Launcher, so its place stays empty.</item>
<item quantity="other">%1$d. They exist only inside Smart Launcher, so their places stay empty.</item>
</plurals>
<string name="flexera_import_skipped">Other items</string>
<plurals name="flexera_import_skipped_value">
<item quantity="one">%1$d that this launcher cannot show or place</item>
<item quantity="other">%1$d that this launcher cannot show or place</item>
</plurals>
<string name="flexera_import_effect">What importing does</string>
<string name="flexera_import_effect_title">Replaces your current home screens</string>
<string name="flexera_import_effect_body">The home screens you have now are removed. The grid takes the size it has in the backup and the dock is switched off, because Smart Launcher has none; both can be changed again in Settings. Icons of apps that are not installed on this phone are dropped. Make a backup first if you want to be able to go back.</string>
<string name="flexera_import_action">Import</string>
<string name="flexera_import_done">Imported</string>
<string name="flexera_import_failed">The import failed</string>
<string name="flexera_import_unreadable">This is not a Smart Launcher backup that can be read</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>
@@ -39,20 +79,51 @@
<!-- The drawer's wave alphabet -->
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
<string name="flexera_alphabet_description">Alphabet</string>
<string name="flexera_row_size">Row size</string>
<string name="flexera_row_size_compact">Compact</string>
<string name="flexera_row_size_comfy">Comfy</string>
<string name="flexera_alphabet_search">Search</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>
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
<string name="flexera_quick_launch">Quick launch</string>
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
<string name="flexera_quick_launch_first_page">Swipe past the first page</string>
<string name="flexera_quick_launch_last_page">Swipe past the last page</string>
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
<!-- Its name in the list of widgets -->
<string name="flexera_picture_widget">Pictures</string>
<string name="flexera_picture_widget_description">One picture, or several that take turns every 10 seconds</string>
<!-- Shown on a picture widget that has no pictures -->
<string name="flexera_picture_widget_empty">Tap to choose pictures</string>
<!-- Read by screen readers. %1$d is the picture being shown, %2$d is how many there are -->
<string name="flexera_picture_widget_position">Picture %1$d of %2$d</string>
<string name="flexera_picture_widget_unreadable">None of the chosen pictures could be read</string>
<!-- Updates section of the About screen (app.flexera.update.FlexeraUpdater) -->
<string name="flexera_update_heading">Updates</string>
<string name="flexera_update_check">Check for updates</string>
<string name="flexera_update_check_idle">Flexera Launcher also checks on its own, once a day</string>
<string name="flexera_update_checking">Checking for updates…</string>
<string name="flexera_update_up_to_date">You have the latest version</string>
<string name="flexera_update_check_failed">Couldn\'t reach the update server. Check your connection, then tap to try again.</string>
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
<string name="flexera_update_failed_local_network">%1$s is on the same network as this phone, and Android lets an app reach devices there only with your permission, which it calls Nearby devices. Tap to allow it. The check then runs again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Tap to try again later.</string>
<!-- %1$s is the error in the system\'s own words, for whoever looks for the cause -->
<string name="flexera_update_failed_detail">Details: %1$s</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_download">Download version %1$s</string>
<!-- %1$s is a file size such as 24 MB -->
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Flexera Launcher's picture widget (app.flexera.widget.PictureWidgetProvider). Two cells by two
when first placed, resizable down to one cell and up to the whole screen. The launcher draws it
itself, so the layouts named here are never shown on the home screen; previewLayout is what
makes the widget list ask for a live preview, which the launcher also draws.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="40dp"
android:minResizeHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:description="@string/flexera_picture_widget_description"
android:configure="app.flexera.widget.PictureWidgetConfigActivity"
android:initialLayout="@layout/flexera_picture_widget_placeholder"
android:previewLayout="@layout/flexera_picture_widget_placeholder"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable" />
@@ -0,0 +1,692 @@
package app.flexera.backup
import android.content.ComponentName
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherApps
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Process
import android.util.Log
import app.flexera.widget.PictureWidgetProvider
import app.flexera.widget.PictureWidgetStore
import app.lawnchair.DeviceProfileOverrides
import app.lawnchair.backup.LawnchairBackup
import app.lawnchair.preferences.PreferenceManager
import app.lawnchair.preferences2.PreferenceManager2
import com.android.launcher3.GridType
import com.android.launcher3.InvariantDeviceProfile
import com.android.launcher3.LauncherSettings.Favorites
import com.android.launcher3.model.DatabaseHelper
import com.android.launcher3.model.DeviceGridState
import com.android.launcher3.model.ModelDbController
import com.android.launcher3.pm.UserCache
import com.android.launcher3.provider.RestoreDbTask
import com.android.launcher3.shortcuts.ShortcutKey
import com.android.launcher3.shortcuts.ShortcutRequest
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.URISyntaxException
import java.util.UUID
import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Imports the home screens of a Smart Launcher backup: icons, folders and widgets, each in the
* place it had, on a grid of the same size and, like Smart Launcher, without a dock.
*
* A backup (a .slbk file) is a zip of Smart Launcher's own files. Two of them matter here:
* - `databases/ginlemon.flower.db`, a SQLite database. Its table `HomeItem` has one row per
* thing on a home screen (screen, cell, size) that points either at a `Launchable`, which
* `Action` describes with an ordinary Android intent, or at a `Widget` with its provider.
* A folder is a launchable too; its members are the launchables that name it as their parent.
* - the preferences file under `shared_prefs/`, where `launcher_profile_#0` holds the grid.
*
* The format is not documented. This follows one real backup, of Smart Launcher's data
* version 64, and refuses a file in which it does not find these tables.
*
* What cannot come along is said to the owner before the import ([Info]): Smart Launcher's own
* widgets, which exist only inside Smart Launcher, and items of other profiles. A widget that
* does come along arrives as "tap to set up": Android has every launcher ask for its widgets
* itself, and keeps a widget's settings with the launcher that made it.
*
* The layout is handed to Launcher3 the way Lawnchair's Nova import hands over its own
* (see [app.lawnchair.backup.NovaBackupConverter]): as a database of Launcher3's own shape,
* which Launcher3's restore code then takes in.
*/
class SmartLauncherBackupConverter(
private val context: Context,
private val uri: Uri,
) {
/** What a backup holds, for the owner to see before agreeing to the import. */
data class Info(
val columns: Int,
val rows: Int,
val screens: Int,
val apps: Int,
val folders: Int,
val appsInFolders: Int,
val widgets: Int,
/** Smart Launcher's photo widgets, which become the launcher's own picture widget. */
val photoWidgets: Int,
/** Smart Launcher's other widgets of its own, which stay behind. */
val ownWidgets: Int,
/** Other things that stay behind: no usable target, another profile, or no free cells. */
val skipped: Int,
)
/** Where a thing sits on the home screens, in whole cells. */
private data class Place(val screen: Int, val x: Int, val y: Int, val width: Int, val height: Int)
/** An icon: an app, or a shortcut into an app ([shortcutId] with [packageName]). */
private data class Icon(
val title: String?,
val intentUri: String,
val packageName: String?,
val shortcutId: String?,
)
private sealed interface HomeThing {
val place: Place
}
private data class HomeIcon(override val place: Place, val icon: Icon) : HomeThing
private data class HomeFolder(override val place: Place, val title: String?, val icons: List<Icon>) : HomeThing
private data class HomeWidget(override val place: Place, val provider: String) : HomeThing
/** One of Smart Launcher's photo widgets, with the unpacked files of its pictures. */
private data class HomePicture(override val place: Place, val photos: List<File>) : HomeThing
private data class Layout(
val columns: Int,
val rows: Int,
val showLabels: Boolean?,
val things: List<HomeThing>,
val ownWidgets: Int,
val skipped: Int,
) {
val info: Info
get() = Info(
columns = columns,
rows = rows,
screens = things.map { it.place.screen }.distinct().size,
apps = things.count { it is HomeIcon },
folders = things.count { it is HomeFolder },
appsInFolders = things.sumOf { (it as? HomeFolder)?.icons?.size ?: 0 },
widgets = things.count { it is HomeWidget },
photoWidgets = things.count { it is HomePicture },
ownWidgets = ownWidgets,
skipped = skipped,
)
}
/** Reads the backup and says what is in it. Throws if it is not a backup this can read. */
suspend fun parseInfo(): Info = withContext(Dispatchers.IO) {
withUnpackedBackup { dir -> readLayout(dir).info }
}
/**
* Replaces the home screens with the backup's. The launcher has to be restarted afterwards
* to show them.
*/
suspend fun convertAndRestore() {
val layout = withContext(Dispatchers.IO) {
// The backup stays unpacked to the end: the photo widgets' pictures are read from it.
withUnpackedBackup { dir -> readLayout(dir).also(::restore) }
}
// Settings that make the result match the source: no dock, because Smart Launcher has
// none, and labels as they were. Last, so that a failed import changes nothing.
val prefs2 = PreferenceManager2.getInstance(context)
prefs2.isHotseatEnabled.set(false)
layout.showLabels?.let { prefs2.showIconLabelsOnHomeScreen.set(it) }
}
/** Puts the layout into the launcher's database and keeps the photo widgets' pictures. */
private fun restore(layout: Layout) {
val tempDir = newTempDir()
try {
val staged = File(tempDir, STAGED_DB)
val shortcuts = writeLauncherDb(layout, staged)
applyGrid(layout)
val restored = context.getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME)
restored.parentFile?.mkdirs()
staged.copyTo(restored, overwrite = true)
// Opening the controller moves the restored file into place; the restore task
// then marks every item as freshly restored, which is what makes Launcher3 ask
// for the widgets anew and drop icons of apps that are not installed.
check(RestoreDbTask.performRestore(context, ModelDbController(context))) {
"Launcher3 did not accept the converted layout"
}
pinShortcuts(shortcuts)
} finally {
tempDir.deleteRecursively()
}
keepPictures(layout)
}
/**
* Puts the pictures of the imported photo widgets where the picture widget finds them. Each
* widget arrives as "tap to set up" like every imported widget; the tap then gives it these
* pictures in place of asking for some (PictureWidgetConfigActivity).
*/
private fun keepPictures(layout: Layout) {
PictureWidgetStore.clearImports(context)
for (thing in layout.things) {
if (thing is HomePicture && thing.photos.isNotEmpty()) {
PictureWidgetStore.stageImport(
context,
thing.place.screen,
thing.place.x,
thing.place.y,
thing.photos.map(Uri::fromFile),
)
}
}
}
// ---- Reading the backup ------------------------------------------------------------------
private fun <T> withUnpackedBackup(block: (File) -> T): T {
val dir = newTempDir()
try {
unpack(dir)
return block(dir)
} finally {
dir.deleteRecursively()
}
}
private fun newTempDir(): File =
File(context.cacheDir, "$TEMP_DIR_PREFIX${UUID.randomUUID()}").apply { mkdirs() }
/**
* Unpacks the database, the preference files and the pictures of the photo widgets, and
* nothing else, into [dir]. Entry names come from the file and are not trusted: only the
* known places are accepted, each file lands under a name made here, and a file of
* unreasonable size ends the import.
*/
private fun unpack(dir: File) {
val descriptor = context.contentResolver.openFileDescriptor(uri, "r")
?: error("Cannot open the backup file")
descriptor.use {
ZipInputStream(FileInputStream(it.fileDescriptor)).use { zip ->
var photos = 0
var entry = zip.nextEntry
while (entry != null) {
val name = entry.name
val photo = SL_PHOTO_ENTRY.matchEntire(name)
val target = when {
entry.isDirectory -> null
name == SL_DB_ENTRY -> File(dir, SL_DB)
name.startsWith(SL_PREFS_DIR) && name.endsWith(".xml") ->
File(dir, PREFS_PREFIX + File(name).name)
// A photo widget's picture, kept under the number of its widget.
photo != null && photos < MAX_PHOTOS ->
File(dir, photoName(photo.groupValues[1], photos++))
else -> null
}
if (target != null) {
require(target.canonicalPath.startsWith(dir.canonicalPath + File.separator)) {
"Backup entry outside the import folder: $name"
}
FileOutputStream(target).use { out ->
val buffer = ByteArray(64 * 1024)
var total = 0L
while (true) {
val read = zip.read(buffer)
if (read < 0) break
total += read
require(total <= MAX_ENTRY_BYTES) { "Backup entry too large: $name" }
out.write(buffer, 0, read)
}
}
}
entry = zip.nextEntry
}
}
}
}
private fun readLayout(dir: File): Layout {
val dbFile = File(dir, SL_DB)
require(dbFile.exists()) { "No Smart Launcher database in this file" }
val grid = readGrid(dir)
SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
for (table in listOf(T_HOME, T_LAUNCHABLE, T_ACTION, T_WIDGET)) {
require(tableExists(db, table)) { "Smart Launcher database without table $table" }
}
return readHomeScreens(db, grid, dir)
}
}
private data class Grid(val columns: Int, val rows: Int, val showLabels: Boolean?)
/** The home grid from the preferences, or null where a backup does not state it. */
private fun readGrid(dir: File): Grid? {
val files = dir.listFiles { file -> file.name.startsWith(PREFS_PREFIX) } ?: return null
for (file in files) {
try {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val strings = document.documentElement.getElementsByTagName("string")
for (i in 0 until strings.length) {
val node = strings.item(i)
val name = node.attributes?.getNamedItem("name")?.nodeValue ?: continue
if (!name.startsWith(SL_PROFILE_KEY_PREFIX)) continue
val settings = JSONObject(node.textContent).optJSONObject("gridUserSettings") ?: continue
val columns = settings.optInt("columns", 0)
val rows = settings.optInt("rows", 0)
if (columns in 1..MAX_GRID && rows in 1..MAX_GRID) {
val labels = if (settings.has("hasLabel")) settings.optBoolean("hasLabel") else null
return Grid(columns, rows, labels)
}
}
} catch (e: Exception) {
Log.w(TAG, "Unreadable preference file in backup: ${file.name}", e)
}
}
return null
}
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?, dir: File): Layout {
data class Row(
val launchable: Long?,
val widget: Long?,
val screen: Int,
val x: Int,
val y: Int,
val width: Int,
val height: Int,
)
// Smart Launcher can keep more than one arrangement ("configId"); the first is the one
// in use on a phone held upright.
val rows = ArrayList<Row>()
db.rawQuery(
"SELECT idLaunchable, idWidget, screen, cellX, cellY, spanX, spanY FROM $T_HOME " +
"WHERE configId = (SELECT MIN(configId) FROM $T_HOME) ORDER BY zIndex, id",
null,
).use { c ->
while (c.moveToNext()) {
rows.add(
Row(
launchable = if (c.isNull(0)) null else c.getLong(0),
widget = if (c.isNull(1)) null else c.getLong(1),
screen = c.getInt(2),
x = c.getDouble(3).roundToInt(),
y = c.getDouble(4).roundToInt(),
width = c.getDouble(5).roundToInt().coerceAtLeast(1),
height = c.getDouble(6).roundToInt().coerceAtLeast(1),
),
)
}
}
val columns = grid?.columns ?: max(MIN_GRID, rows.maxOfOrNull { it.x + it.width } ?: 0)
val gridRows = grid?.rows ?: max(MIN_GRID, rows.maxOfOrNull { it.y + it.height } ?: 0)
// Launcher3 numbers its screens from 0 without gaps, left to right.
val screenIndex = rows.map { it.screen }.distinct().sorted().withIndex().associate { it.value to it.index }
val taken = HashSet<Triple<Int, Int, Int>>()
val things = ArrayList<HomeThing>()
var ownWidgets = 0
var skipped = 0
for (row in rows) {
val screen = screenIndex.getValue(row.screen)
val place = Place(screen, row.x, row.y, row.width, row.height)
val cells = cellsOf(place)
// Launcher3 keeps one thing per cell and nothing outside the grid.
val fits = row.x >= 0 && row.y >= 0 && row.x + row.width <= columns &&
row.y + row.height <= gridRows && cells.none { it in taken }
val read: HomeThing? = when {
row.widget != null -> readWidget(db, row.widget, place, dir)
row.launchable != null -> readLaunchable(db, row.launchable, place)
else -> null
}
when {
// The only widgets with no counterpart here are Smart Launcher's own.
read == null && row.widget != null -> ownWidgets++
read == null || !fits -> skipped++
else -> {
things.add(read)
taken.addAll(cells)
}
}
}
return Layout(columns, gridRows, grid?.showLabels, things, ownWidgets, skipped)
}
private fun cellsOf(place: Place): List<Triple<Int, Int, Int>> = buildList {
for (x in place.x until place.x + place.width) {
for (y in place.y until place.y + place.height) {
add(Triple(place.screen, x, y))
}
}
}
/**
* What a widget of the backup becomes. An ordinary app widget keeps its provider. Smart
* Launcher's photo widget becomes the launcher's own picture widget, with the pictures the
* backup holds for it. Smart Launcher's other widgets have no counterpart: null.
*/
private fun readWidget(db: SQLiteDatabase, id: Long, place: Place, dir: File): HomeThing? =
db.rawQuery("SELECT type, provider, userId FROM $T_WIDGET WHERE id = ?", arrayOf(id.toString())).use { c ->
if (!c.moveToFirst()) {
null
} else {
val type = c.getInt(0)
val provider = if (c.isNull(1)) null else c.getString(1)
val user = if (c.isNull(2)) OWNER_USER else c.getInt(2)
val component = provider?.let(ComponentName::unflattenFromString)
when {
type == SL_WIDGET_TYPE_APP && component != null && user == OWNER_USER ->
HomeWidget(place, component.flattenToString())
type != SL_WIDGET_TYPE_APP && provider?.endsWith(SL_PHOTO_WIDGET_CLASS) == true -> {
val prefix = photoName(id.toString(), null)
val photos = dir.listFiles { file -> file.name.startsWith(prefix) }
HomePicture(place, photos?.sortedBy { it.name }.orEmpty())
}
else -> null
}
}
}
/** The icon or folder behind a launchable, or null if it has no target this can use. */
private fun readLaunchable(db: SQLiteDatabase, id: Long, place: Place): HomeThing? {
val action = readAction(db, id) ?: return null
if (action.type != SL_ACTION_TYPE_FOLDER) {
return action.toIcon()?.let { HomeIcon(place, it) }
}
val icons = ArrayList<Icon>()
db.rawQuery(
"SELECT id FROM $T_LAUNCHABLE WHERE idParentFolderLaunchable = ? ORDER BY position, id",
arrayOf(id.toString()),
).use { c ->
while (c.moveToNext()) {
readAction(db, c.getLong(0))?.takeIf { it.type != SL_ACTION_TYPE_FOLDER }?.toIcon()?.let(icons::add)
}
}
// Launcher3 has no use for a folder with nothing in it.
return if (icons.isEmpty()) null else HomeFolder(place, action.label, icons)
}
private data class Action(
val type: Int,
val intentUri: String?,
val user: Int,
val label: String?,
val shortcutId: String?,
) {
/** Launcher3's form of this action, if it opens an app or one of an app's shortcuts. */
fun toIcon(): Icon? {
// Icons of a work profile or a second user are left out; -1 means "no user", as on folders.
if (user != OWNER_USER && user != NO_USER) return null
val intent = try {
Intent.parseUri(intentUri ?: return null, 0)
} catch (_: URISyntaxException) {
return null
}
val component = intent.component
val packageName = component?.packageName ?: intent.`package`
return when {
!shortcutId.isNullOrEmpty() && packageName != null ->
Icon(label, ShortcutKey.makeIntent(shortcutId, packageName).toUri(0), packageName, shortcutId)
component != null -> Icon(label, launchIntentFor(component).toUri(0), packageName, null)
else -> null
}
}
}
/** The first action of a launchable, which is what a tap does. */
private fun readAction(db: SQLiteDatabase, launchable: Long): Action? =
db.rawQuery(
"SELECT type, intentUri, userId, label, deepShortcutId FROM $T_ACTION " +
"WHERE idLaunchable = ? ORDER BY actionId LIMIT 1",
arrayOf(launchable.toString()),
).use { c ->
if (!c.moveToFirst()) {
null
} else {
Action(
type = c.getInt(0),
intentUri = if (c.isNull(1)) null else c.getString(1),
user = if (c.isNull(2)) OWNER_USER else c.getInt(2),
label = if (c.isNull(3)) null else c.getString(3),
shortcutId = if (c.isNull(4)) null else c.getString(4),
)
}
}
private fun tableExists(db: SQLiteDatabase, table: String): Boolean =
db.rawQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", arrayOf(table)).use {
it.moveToFirst()
}
// ---- Writing Launcher3's layout ----------------------------------------------------------
/**
* Writes [layout] as a Launcher3 database at [target]. Returns the app shortcuts in it, by
* package, which the launcher has to pin before it may show them.
*/
private fun writeLauncherDb(layout: Layout, target: File): Map<String, Set<String>> {
val profileId = UserCache.INSTANCE.get(context).getSerialNumberForUser(Process.myUserHandle())
val shortcuts = HashMap<String, MutableSet<String>>()
val now = System.currentTimeMillis()
var nextId = 1
SQLiteDatabase.openOrCreateDatabase(target, null).use { db ->
db.version = DatabaseHelper.SCHEMA_VERSION
Favorites.addTableToDb(db, profileId, false)
fun insert(
container: Int,
place: Place?,
rank: Int,
itemType: Int,
title: String? = null,
intentUri: String? = null,
provider: String? = null,
): Int {
val id = nextId++
db.insertOrThrow(
Favorites.TABLE_NAME,
null,
ContentValues().apply {
put(Favorites._ID, id)
put(Favorites.TITLE, title)
put(Favorites.INTENT, intentUri)
put(Favorites.CONTAINER, container)
put(Favorites.SCREEN, place?.screen ?: 0)
put(Favorites.CELLX, place?.x ?: 0)
put(Favorites.CELLY, place?.y ?: 0)
put(Favorites.SPANX, place?.width ?: 1)
put(Favorites.SPANY, place?.height ?: 1)
put(Favorites.ITEM_TYPE, itemType)
put(Favorites.APPWIDGET_ID, -1)
put(Favorites.APPWIDGET_PROVIDER, provider)
put(Favorites.MODIFIED, now)
put(Favorites.RESTORED, 0)
put(Favorites.PROFILE_ID, profileId)
put(Favorites.RANK, rank)
put(Favorites.OPTIONS, 0)
put(Favorites.APPWIDGET_SOURCE, -1)
},
)
return id
}
fun insertIcon(icon: Icon, container: Int, place: Place?, rank: Int) {
val isShortcut = icon.shortcutId != null && icon.packageName != null
if (isShortcut) {
shortcuts.getOrPut(icon.packageName!!) { HashSet() }.add(icon.shortcutId!!)
}
insert(
container = container,
place = place,
rank = rank,
itemType = if (isShortcut) Favorites.ITEM_TYPE_DEEP_SHORTCUT else Favorites.ITEM_TYPE_APPLICATION,
title = icon.title,
intentUri = icon.intentUri,
)
}
db.beginTransaction()
try {
for (thing in layout.things) {
when (thing) {
is HomeIcon -> insertIcon(thing.icon, Favorites.CONTAINER_DESKTOP, thing.place, 0)
is HomeFolder -> {
val folder = insert(
container = Favorites.CONTAINER_DESKTOP,
place = thing.place,
rank = 0,
itemType = Favorites.ITEM_TYPE_FOLDER,
title = thing.title,
)
thing.icons.forEachIndexed { rank, icon -> insertIcon(icon, folder, null, rank) }
}
is HomeWidget -> insert(
container = Favorites.CONTAINER_DESKTOP,
place = thing.place,
rank = 0,
itemType = Favorites.ITEM_TYPE_APPWIDGET,
provider = thing.provider,
)
is HomePicture -> insert(
container = Favorites.CONTAINER_DESKTOP,
place = thing.place,
rank = 0,
itemType = Favorites.ITEM_TYPE_APPWIDGET,
provider = PictureWidgetProvider.componentName.flattenToString(),
)
}
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
return shortcuts
}
/** Makes the home grid the size of the backup's, in every place the launcher keeps it. */
private fun applyGrid(layout: Layout) {
val prefs = PreferenceManager.getInstance(context)
val hotseat = prefs.hotseatColumns.get()
val gridInfo = DeviceProfileOverrides.DBGridInfo(
numHotseatColumns = hotseat,
numRows = layout.rows,
numColumns = layout.columns,
)
val gridState = DeviceGridState(
layout.columns,
layout.rows,
hotseat,
InvariantDeviceProfile.TYPE_PHONE,
gridInfo.dbFile,
GridType.GRID_TYPE_ANY,
)
gridState.writeToPrefs(context, true)
gridState.writeToPrefs(context)
InvariantDeviceProfile.INSTANCE.get(context).dbFile = gridInfo.dbFile
prefs.sp.edit()
.putInt(prefs.workspaceColumns.key, layout.columns)
.putInt(prefs.workspaceRows.key, layout.rows)
.commit()
}
/** Pins the imported app shortcuts, keeping whatever the launcher has pinned already. */
private fun pinShortcuts(shortcuts: Map<String, Set<String>>) {
if (shortcuts.isEmpty()) return
val launcherApps = context.getSystemService(LauncherApps::class.java) ?: return
val user = Process.myUserHandle()
for ((packageName, ids) in shortcuts) {
val pinned = LinkedHashSet<String>()
val current = ShortcutRequest(context, user)
.forPackage(packageName)
.query(ShortcutRequest.PINNED or LauncherApps.ShortcutQuery.FLAG_GET_KEY_FIELDS_ONLY)
if (current.wasSuccess()) current.mapTo(pinned) { it.id }
pinned.addAll(ids)
try {
launcherApps.pinShortcuts(packageName, pinned.toList(), user)
} catch (e: SecurityException) {
Log.w(TAG, "Could not pin the imported shortcuts of $packageName", e)
} catch (e: IllegalStateException) {
Log.w(TAG, "Could not pin the imported shortcuts of $packageName", e)
}
}
}
private companion object {
const val TAG = "SmartLauncherImport"
const val SL_DB_ENTRY = "databases/ginlemon.flower.db"
const val SL_PREFS_DIR = "shared_prefs/"
const val SL_PROFILE_KEY_PREFIX = "launcher_profile_"
const val T_HOME = "HomeItem"
const val T_LAUNCHABLE = "Launchable"
const val T_ACTION = "Action"
const val T_WIDGET = "Widget"
/** `Action.type` of a folder; every other type seen opens an app. */
const val SL_ACTION_TYPE_FOLDER = 9
/** `Widget.type` of an ordinary app widget; the other types are Smart Launcher's own. */
const val SL_WIDGET_TYPE_APP = 1
/** How `Widget.provider` ends for Smart Launcher's photo widget, in every edition of it. */
const val SL_PHOTO_WIDGET_CLASS = ".PhotoWidget"
/** Where a backup keeps the pictures of a photo widget: a folder named by `Widget.id`. */
val SL_PHOTO_ENTRY = Regex("files/widgetPhoto/(\\d{1,18})/[^/]+")
/** More pictures than this in one backup are not taken along. */
const val MAX_PHOTOS = 100
/**
* The name an unpacked picture gets: its widget's number, then its turn. Without a turn,
* the start of the names of all pictures of that widget.
*/
fun photoName(widget: String, turn: Int?): String = "photo_${widget}_" + (turn?.toString() ?: "")
/** Smart Launcher's user ids: the phone's owner, and "no user" on folders. */
const val OWNER_USER = 0
const val NO_USER = -1
const val TEMP_DIR_PREFIX = "smartlauncher_"
const val SL_DB = "smartlauncher.db"
const val PREFS_PREFIX = "prefs_"
const val STAGED_DB = "workspace.db"
/** Far above any real backup's database; a larger entry is not a backup. */
const val MAX_ENTRY_BYTES = 64L * 1024 * 1024
const val MIN_GRID = 4
const val MAX_GRID = 20
/** The intent Launcher3 itself stores for an app icon. */
fun launchIntentFor(component: ComponentName): Intent =
Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(component)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
}
@@ -0,0 +1,208 @@
package app.flexera.backup
import android.app.Activity
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import app.flexera.backup.SmartLauncherImportViewModel.Event
import app.flexera.backup.SmartLauncherImportViewModel.State
import app.lawnchair.ui.preferences.LocalIsExpandedScreen
import app.lawnchair.ui.preferences.LocalNavController
import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
import app.lawnchair.ui.preferences.navigation.ImportSmartLauncherBackup
import app.lawnchair.util.BackHandler
import app.lawnchair.util.restartLauncher
import com.android.launcher3.R
import java.util.Base64
/**
* The screen between choosing a Smart Launcher backup and importing it. It says what the file
* holds, what will not come along and what the import replaces, and waits for the owner's word.
*/
fun NavGraphBuilder.smartLauncherImportGraph() {
composable<ImportSmartLauncherBackup> { entry ->
val route: ImportSmartLauncherBackup = entry.toRoute()
val backup = remember { String(Base64.getDecoder().decode(route.base64Uri)).toUri() }
val viewModel: SmartLauncherImportViewModel = viewModel()
DisposableEffect(Unit) {
viewModel.open(backup)
onDispose { }
}
SmartLauncherImportScreen(viewModel)
}
}
@Composable
private fun SmartLauncherImportScreen(viewModel: SmartLauncherImportViewModel) {
val context = LocalContext.current
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
Event.Imported -> {
Toast.makeText(context, R.string.flexera_import_done, Toast.LENGTH_SHORT).show()
restartLauncher(context)
}
Event.Failed -> Toast.makeText(context, R.string.flexera_import_failed, Toast.LENGTH_LONG).show()
}
}
}
PreferenceLayout(
label = stringResource(id = R.string.flexera_import_smart_launcher),
backArrowVisible = !LocalIsExpandedScreen.current,
) {
when (val current = state) {
State.Reading -> Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
State.Unreadable -> {
val back = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
LaunchedEffect(Unit) {
Toast.makeText(context, R.string.flexera_import_unreadable, Toast.LENGTH_LONG).show()
back?.onBackPressed()
}
}
is State.Ready -> {
// Leaving in the middle of an import would leave the home screens half replaced.
if (current.importing) BackHandler {}
BackupContents(current.info)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.padding(horizontal = 16.dp),
) {
Button(
onClick = viewModel::import,
modifier = Modifier.fillMaxWidth(),
enabled = !current.importing,
) {
Text(text = stringResource(id = R.string.flexera_import_action))
}
}
}
}
}
}
@Composable
private fun BackupContents(info: SmartLauncherBackupConverter.Info) {
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_found)) {
InfoRow(
R.string.flexera_import_screens,
stringResource(id = R.string.flexera_import_screens_value, info.screens, info.columns, info.rows),
)
if (info.apps > 0) InfoRow(R.string.flexera_import_apps, info.apps.toString())
if (info.folders > 0) {
InfoRow(
R.string.flexera_import_folders,
pluralStringResource(
id = R.plurals.flexera_import_folders_value,
count = info.appsInFolders,
info.folders,
info.appsInFolders,
),
)
}
if (info.widgets > 0) {
InfoRow(R.string.flexera_import_widgets, stringResource(id = R.string.flexera_import_widgets_value, info.widgets))
}
if (info.photoWidgets > 0) {
InfoRow(
R.string.flexera_import_photo_widgets,
pluralStringResource(
id = R.plurals.flexera_import_photo_widgets_value,
count = info.photoWidgets,
info.photoWidgets,
),
)
}
}
if (info.ownWidgets > 0 || info.skipped > 0) {
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_left_out)) {
if (info.ownWidgets > 0) {
InfoRow(
R.string.flexera_import_own_widgets,
pluralStringResource(
id = R.plurals.flexera_import_own_widgets_value,
count = info.ownWidgets,
info.ownWidgets,
),
)
}
if (info.skipped > 0) {
InfoRow(
R.string.flexera_import_skipped,
pluralStringResource(id = R.plurals.flexera_import_skipped_value, count = info.skipped, info.skipped),
)
}
}
}
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_effect)) {
PreferenceTemplate(
title = { Text(text = stringResource(id = R.string.flexera_import_effect_title)) },
description = { Text(text = stringResource(id = R.string.flexera_import_effect_body)) },
)
}
}
@Composable
private fun InfoRow(labelRes: Int, value: String) {
PreferenceTemplate(
title = { Text(text = stringResource(id = labelRes)) },
description = { Text(text = value) },
)
}
/** Returns an action that lets the owner pick a backup file and then opens the import screen for it. */
@Composable
fun smartLauncherImportOpener(): () -> Unit {
val navController = LocalNavController.current
val request = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
val base64Uri = Base64.getEncoder().encodeToString(uri.toString().toByteArray())
navController.navigate(ImportSmartLauncherBackup(base64Uri))
}
return {
// A .slbk file has no file type Android knows, so every file is offered.
request.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*"),
)
}
}
@@ -0,0 +1,74 @@
package app.flexera.backup
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
/**
* Holds one Smart Launcher import: reads the chosen backup, shows what is in it, and on the
* owner's word replaces the home screens with it. Same shape as Lawnchair's view model for a
* Nova backup.
*/
internal class SmartLauncherImportViewModel(application: Application) : AndroidViewModel(application) {
sealed interface Event {
data object Imported : Event
data object Failed : Event
}
sealed interface State {
data object Reading : State
data object Unreadable : State
data class Ready(val info: SmartLauncherBackupConverter.Info, val importing: Boolean = false) : State
}
private var converter: SmartLauncherBackupConverter? = null
private val mutableState = MutableStateFlow<State>(State.Reading)
val state: StateFlow<State> = mutableState.asStateFlow()
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val events = eventChannel.receiveAsFlow()
fun open(backup: Uri) {
if (converter != null) return
val converter = SmartLauncherBackupConverter(getApplication(), backup).also { converter = it }
viewModelScope.launch {
mutableState.value = try {
State.Ready(converter.parseInfo())
} catch (t: Throwable) {
Log.e(TAG, "Not a Smart Launcher backup this can read", t)
State.Unreadable
}
}
}
fun import() {
val ready = mutableState.value as? State.Ready ?: return
val converter = converter ?: return
if (ready.importing) return
viewModelScope.launch {
mutableState.value = ready.copy(importing = true)
try {
converter.convertAndRestore()
eventChannel.send(Event.Imported)
} catch (t: Throwable) {
Log.e(TAG, "Import failed", t)
mutableState.value = ready.copy(importing = false)
eventChannel.send(Event.Failed)
}
}
}
private companion object {
const val TAG = "SmartLauncherImport"
}
}
+167 -9
View File
@@ -16,7 +16,10 @@ import com.android.launcher3.BubbleTextView
import com.android.launcher3.R
import com.android.launcher3.allapps.ActivityAllAppsContainerView
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
import androidx.recyclerview.widget.RecyclerView
import com.android.launcher3.views.BaseDragLayer
import kotlin.math.max
import kotlin.math.min
/**
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
@@ -44,9 +47,54 @@ object FlexeraDrawer {
@JvmStatic
fun sectionHeader(sectionName: String): AdapterItem = SectionHeaderItem(sectionName)
/** The sizes the owner can give the list's rows (Settings, App drawer). */
enum class RowSize(val key: String) { COMPACT("compact"), COMFY("comfy") }
/** The row size in use. Changing the setting recreates the launcher. */
@JvmStatic
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View =
inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false).also(::applySidePadding)
fun rowSize(context: Context): RowSize {
val key = PreferenceManager.getInstance(context).flexeraDrawerRowSize.get()
return RowSize.entries.firstOrNull { it.key == key } ?: RowSize.COMPACT
}
/** Picks the dimension of the row size in use. */
private fun sized(context: Context, compact: Int, comfy: Int): Int =
if (rowSize(context) == RowSize.COMFY) comfy else compact
/** How tall a letter header is. */
@JvmStatic
fun headerHeight(context: Context): Int = context.resources.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_header_height, R.dimen.flexera_drawer_header_height_comfy),
)
@JvmStatic
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View {
val header = inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false) as TextView
val context = header.context
val res = context.resources
// The layout carries the compact sizes; the row size in use has the last word.
header.layoutParams.height = headerHeight(context)
header.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
res.getDimension(
sized(context, R.dimen.flexera_drawer_header_text_size, R.dimen.flexera_drawer_header_text_size_comfy),
),
)
header.setPadding(
header.paddingLeft,
header.paddingTop,
header.paddingRight,
res.getDimensionPixelSize(
sized(
context,
R.dimen.flexera_drawer_header_padding_bottom,
R.dimen.flexera_drawer_header_padding_bottom_comfy,
),
),
)
applySidePadding(header)
return header
}
/**
* True when the owner has moved the wave alphabet to the left edge (PRD AD-15). Changing the
@@ -87,9 +135,20 @@ object FlexeraDrawer {
@JvmStatic
fun styleAppRow(icon: BubbleTextView) {
val res = icon.resources
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))
// 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 context = icon.context
icon.layoutParams.height = rowHeight(context)
icon.setIconSizeOverride(
res.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_row_icon_size, R.dimen.flexera_drawer_row_icon_size_comfy),
),
)
icon.compoundDrawablePadding = res.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_row_icon_gap, R.dimen.flexera_drawer_row_icon_gap_comfy),
)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, rowTextSize(context))
applySidePadding(icon)
// The drawer has no background, so names sit directly on the dimmed wallpaper: white,
// with a soft shadow to stay readable over light areas.
@@ -105,12 +164,89 @@ object FlexeraDrawer {
/** Shadow behind white drawer text. */
const val TEXT_SHADOW_COLOR = 0x73000000
/**
* How tall an app row is. 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.
*/
@JvmStatic
fun rowHeight(context: Context): Int = max(
context.resources.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_row_height, R.dimen.flexera_drawer_row_height_comfy),
),
(rowTextSize(context) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
/** The size of an app name in a row, in pixels. */
private fun rowTextSize(context: Context): Float = context.resources.getDimension(
sized(context, R.dimen.flexera_drawer_row_text_size, R.dimen.flexera_drawer_row_text_size_comfy),
)
/** 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.
*/
const val KEEP_SEARCH_FIELD_BACKGROUND = true
/**
* Moves the search field up with the list, so that it scrolls away like the list's first
* row and comes back when the list is at its top. The field is not a row of the list: it
* lies over the list's top padding and is shifted by as much as the list has scrolled.
* While a search is on, and where the drawer does not use this (work tabs, a hidden or a
* floating search field), the field stays in place.
*/
@JvmStatic
fun moveSearchFieldWithList(appsView: ActivityAllAppsContainerView<*>) {
val field = appsView.searchView ?: return
val list = appsView.activeRecyclerView
val scrolled = if (list == null || appsView.isSearching || !appsView.flexeraSearchFieldScrolls()) {
0
} else {
scrolledFromTop(list)
}
// The field and the list's rows vanish at the same line, the top of the drawer's
// content, which is the lower edge of the status bar. The rows are cut there by the
// list's own top edge; the field is cut here, and travels no further than it takes to
// be all above that line.
val line = appsView.paddingTop
val moved = min(scrolled, max(0, field.bottom - line))
field.translationY = -moved.toFloat()
val hidden = line - (field.top - moved)
if (hidden > 0) {
// The view copies the rectangle, so one scratch rectangle serves every call.
fieldClip.set(0, hidden, field.width, max(hidden, field.height))
field.clipBounds = fieldClip
} else {
field.clipBounds = null
}
}
private val fieldClip = Rect()
/**
* How far [list] has scrolled from its top, in pixels, for as long as its first row is
* still attached; after that, simply "far". Counting rows is exact, which the list's own
* scroll offset, an estimate from row heights, is not.
*/
private fun scrolledFromTop(list: RecyclerView): Int {
val first = list.getChildAt(0) ?: return 0
return if (list.getChildAdapterPosition(first) == 0) {
max(0, list.paddingTop - first.top)
} else {
Int.MAX_VALUE
}
}
/**
* Where the drawer panel sits for a given all-apps progress (0 = open, 1 = closed). Launcher3
* slides the panel up from the bottom; here it stays in place and fades (see
@@ -197,12 +333,19 @@ object FlexeraDrawer {
appsView: ActivityAllAppsContainerView<*>,
dragLayer: BaseDragLayer<*>,
ev: MotionEvent,
) = scrubFromSwipe(appsView, dragLayer, ev.x, ev.y)
/** The same, for a finger position ([x], [y]) in the drag layer's coordinates. */
@JvmStatic
fun scrubFromSwipe(
appsView: ActivityAllAppsContainerView<*>,
dragLayer: BaseDragLayer<*>,
x: Float,
y: Float,
) {
val wave = waveAlphabet(appsView) ?: return
val offset = layoutOffset(wave, dragLayer)
val x = ev.x - offset.x
val y = ev.y - offset.y
if (wave.isScrubbing) wave.moveScrub(x, y) else wave.beginScrub(x, y)
if (wave.isScrubbing) wave.moveScrub(x - offset.x, y - offset.y) else wave.beginScrub(x - offset.x, y - offset.y)
}
/**
@@ -217,7 +360,7 @@ object FlexeraDrawer {
): Boolean {
val wave = waveAlphabet(appsView) ?: return false
val offset = layoutOffset(wave, dragLayer)
return wave.isOnStripBand(ev.x - offset.x, ev.y - offset.y)
return wave.isOnHomeBand(ev.x - offset.x, ev.y - offset.y)
}
/**
@@ -236,12 +379,27 @@ object FlexeraDrawer {
return offset
}
/**
* A scrub that started from a swipe and ends sooner than this was not meant as one: nobody
* reads a letter and means it in a fifth of a second. The swipe opens the plain list.
*/
const val MIN_SCRUB_MS = 200L
/** Ends a scrub started by [scrubFromSwipe]; the drawer stays open at the chosen letter. */
@JvmStatic
fun endScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
waveAlphabet(appsView)?.endScrub()
}
/**
* Ends a scrub started by [scrubFromSwipe] and undoes it, so the drawer opens the way an
* ordinary swipe up leaves it.
*/
@JvmStatic
fun cancelScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
waveAlphabet(appsView)?.cancelScrub()
}
/**
* A letter header. It carries its letter so that the adapter's diffing notices when the
* header at a position changes, for example after an app is installed or removed.
@@ -1,5 +1,6 @@
package app.flexera.drawer
import android.os.SystemClock
import android.view.MotionEvent
import android.view.ViewConfiguration
import app.lawnchair.preferences.PreferenceManager
@@ -16,7 +17,9 @@ import kotlin.math.abs
* touch. Nothing is drawn on the home screen; the letters appear with the drawer.
*
* The edge is shared with page swipes, taps and item drags, so this claims a touch only once it
* has clearly moved up or down, and never while an item is being dragged. It is off by default.
* has clearly moved up or down, and never while an item is being dragged. A touch that lifts
* again within [FlexeraDrawer.MIN_SCRUB_MS] was a flick, not a scrub, and opens the plain list.
* The setting is on unless the owner switches it off.
*/
class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchController {
@@ -26,6 +29,7 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
/** The current touch started on the edge and may still turn into a scrub. */
private var candidate = false
private var scrubbing = false
private var scrubStartTime = 0L
private var downX = 0f
private var downY = 0f
@@ -48,7 +52,11 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
dy > touchSlop && dy > dx * VERTICAL_BIAS -> {
candidate = false
scrubbing = true
launcher.stateManager.goToState(LauncherState.ALL_APPS)
scrubStartTime = SystemClock.uptimeMillis()
// The drawer starts opening and the alphabet takes the finger in the same
// moment. Opening comes first, because its start is when the drawer
// puts its list in place, which must not undo the scrub's first jump.
openDrawer()
FlexeraDrawer.scrubFromSwipe(launcher.appsView, launcher.dragLayer, ev)
return true
}
@@ -68,12 +76,28 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
scrubbing = false
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
if (SystemClock.uptimeMillis() - scrubStartTime < FlexeraDrawer.MIN_SCRUB_MS) {
// A flick up that happened to start on the edge: open the plain list.
FlexeraDrawer.cancelScrubFromSwipe(launcher.appsView)
} else {
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
}
}
}
return true
}
/**
* Opens the drawer in [OPEN_MS]. Asking the state manager to go there would take the
* drawer's own 600 ms, far too slow for a list that the finger is already scrubbing.
*/
private fun openDrawer() {
launcher.stateManager.createAnimationToNewWorkspace(LauncherState.ALL_APPS, OPEN_MS).apply {
dispatchOnStart()
start()
}
}
private fun canStart(ev: MotionEvent): Boolean =
prefs.flexeraScrubFromHome.get() &&
(ev.edgeFlags and Utilities.EDGE_NAV_BAR) == 0 &&
@@ -86,5 +110,8 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
private companion object {
/** A slide counts as vertical when it has moved this many times further up or down than sideways. */
const val VERTICAL_BIAS = 2f
/** How long the drawer takes to open under a scrub; the same as after swipe up and hold. */
const val OPEN_MS = 120L
}
}
@@ -0,0 +1,132 @@
package app.flexera.drawer
import android.app.Notification
import android.service.notification.StatusBarNotification
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextUtils
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import app.lawnchair.preferences.PreferenceManager
import com.android.launcher3.BubbleTextView
import com.android.launcher3.R
import com.android.launcher3.dot.DotInfo
import com.android.launcher3.model.data.ItemInfo
import java.util.concurrent.ConcurrentHashMap
/**
* Inline notification previews (PRD AD-11): in the drawer list, a row whose app has
* notifications shows the latest one under the app name, smaller and dimmer.
*
* It rides on what Launcher3 already has for notification dots. The same notification access
* feeds it, the same filter decides which notifications count, and a row is refreshed whenever
* its dot is. Without notification access there are no dots and no previews.
*
* Launcher3's listener hands each notification to [onPosted], [onRemoved] or [onFullRefresh] on
* its worker thread; the rows read the stored text on the main thread through [apply].
*/
object FlexeraNotificationPreview {
private class Preview(val text: String, val postTime: Long)
/** Latest text per notification key, for the notifications that currently show a dot. */
private val previews = ConcurrentHashMap<String, Preview>()
private const val MAX_LENGTH = 140
/** The preview line's text size as a share of the app name's. */
private const val TEXT_SCALE = 0.78f
/** White at 70%, so the app name stays the stronger of the two lines. */
private const val TEXT_COLOR = 0xB3FFFFFF.toInt()
@JvmStatic
fun onPosted(sbn: StatusBarNotification) {
val text = previewText(sbn)
if (text == null) previews.remove(sbn.key) else previews[sbn.key] = Preview(text, sbn.postTime)
}
@JvmStatic
fun onRemoved(sbn: StatusBarNotification) {
previews.remove(sbn.key)
}
@JvmStatic
fun onFullRefresh(active: List<StatusBarNotification>) {
previews.clear()
active.forEach(::onPosted)
}
/**
* Sets [icon]'s text for [info]: the app name, plus the latest notification under it when
* [dot] says the app has any. Called for drawer rows whenever their dot state is applied.
*/
@JvmStatic
fun apply(icon: BubbleTextView, info: ItemInfo, dot: DotInfo?) {
// Only rows of the drawer list have room for a second line.
if (icon.getTag(R.id.flexera_drawer_row) == null) return
val name = info.title ?: return
val preview = if (dot != null && isEnabled(icon)) latest(dot) else null
if (preview == null) {
if (icon.getTag(R.id.flexera_notification_preview) != null) {
// The row was showing a preview, for this app earlier or for another app before
// the view was reused. Back to the plain one-line name.
icon.setTag(R.id.flexera_notification_preview, null)
icon.setLines(1)
icon.text = name
}
return
}
// The text is set afresh every time. Binding a row puts the plain name back first, so
// what the row showed before says nothing about what it shows now.
// Keep a long app name on its own line, so the preview is never pushed off the row.
val width = icon.width - icon.compoundPaddingLeft - icon.compoundPaddingRight
val firstLine = if (width > 0) {
TextUtils.ellipsize(name, icon.paint, width.toFloat(), TextUtils.TruncateAt.END)
} else {
name
}
val label = SpannableStringBuilder(firstLine).append('\n')
val start = label.length
label.append(preview)
label.setSpan(RelativeSizeSpan(TEXT_SCALE), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
label.setSpan(ForegroundColorSpan(TEXT_COLOR), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
icon.setLines(2)
icon.ellipsize = TextUtils.TruncateAt.END
icon.text = label
icon.setTag(R.id.flexera_notification_preview, true)
}
private fun isEnabled(icon: BubbleTextView): Boolean =
PreferenceManager.getInstance(icon.context).flexeraNotificationPreviews.get()
/** The text of the newest of the app's notifications that has any text. */
private fun latest(dot: DotInfo): String? {
var newest: Preview? = null
for (key in dot.notificationKeys) {
val candidate = previews[key.notificationKey] ?: continue
if (newest == null || candidate.postTime > newest.postTime) newest = candidate
}
return newest?.text
}
/** "Title: text" in one line, or whichever of the two the notification has. */
private fun previewText(sbn: StatusBarNotification): String? {
val extras = sbn.notification.extras ?: return null
val title = extras.getCharSequence(Notification.EXTRA_TITLE).oneLine()
val text = extras.getCharSequence(Notification.EXTRA_TEXT).oneLine()
val combined = when {
title.isEmpty() -> text
text.isEmpty() -> title
else -> "$title: $text"
}
return combined.take(MAX_LENGTH).ifEmpty { null }
}
private fun CharSequence?.oneLine(): String =
this?.toString()?.replace('\n', ' ')?.trim().orEmpty()
}
@@ -13,6 +13,7 @@ import android.os.Bundle
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewTreeObserver
import android.view.animation.DecelerateInterpolator
import android.widget.SeekBar
@@ -24,6 +25,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
@@ -35,16 +38,22 @@ import kotlin.math.roundToInt
/**
* The wave alphabet: a strip of letters down one edge of the drawer (PRD AD-4 to AD-6).
*
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
* Only letters that have apps are shown, under a magnifier that stands for the search field at
* the top of the list. 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.
* that start on the strip; every other touch falls through to the list underneath. The very
* edge is also where Android starts its Back swipe. The strip leaves that to Android: a touch
* there becomes a scrub only once it moves up or down or rests, and a sideways swipe goes back.
*
* [beginScrub], [moveScrub] and [endScrub] are public so that another gesture, such as swipe up
* and hold from the home screen (PRD AD-17), can hand its touch over to the alphabet.
@@ -58,7 +67,8 @@ class WaveAlphabetView(
private val appsView: ActivityAllAppsContainerView<*>,
) : View(context) {
private data class Letter(val label: String, val adapterPosition: Int)
/** One stop on the strip: a letter, or the search field at the top of the list ([isSearch]). */
private data class Letter(val label: String, val adapterPosition: Int, val isSearch: Boolean = false)
private val res = context.resources
private val onLeft = FlexeraDrawer.alphabetOnLeft(context)
@@ -66,10 +76,15 @@ 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)
private val headerHeight = FlexeraDrawer.headerHeight(context)
private val homeTouchWidth = res.getDimension(R.dimen.flexera_wave_home_touch_width)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
/** 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)
@@ -91,6 +106,43 @@ class WaveAlphabetView(
}
private val boldTypeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
// For the magnifier: one stroke with the letters' shadow, one without for the bubble.
private val markPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
setShadowLayer(
res.getDimension(R.dimen.flexera_drawer_text_shadow_radius),
0f,
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
FlexeraDrawer.TEXT_SHADOW_COLOR,
)
}
private val plainMarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
// 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
@@ -106,11 +158,25 @@ class WaveAlphabetView(
private var fingerY = 0f
private var selected = -1
// A touch that began on the edge strip Android uses for Back, before it is known whether
// it is a scrub: where it went down, where it is now, and the timer that makes a touch
// at rest a scrub.
private var waitingForBack = false
private var downX = 0f
private var downY = 0f
private var lastX = 0f
private var lastY = 0f
private val scrubAfterRest = Runnable {
if (waitingForBack) {
waitingForBack = false
beginScrub(lastX, lastY)
}
}
/** 0 when the strip is at rest, 1 when the wave is fully out. Animated on touch down and up. */
private var press = 0f
private var pressAnimator: ValueAnimator? = null
private val exclusionRect = Rect()
private val location = IntArray(2)
private val otherLocation = IntArray(2)
@@ -138,10 +204,18 @@ class WaveAlphabetView(
if (result.isNotEmpty() && result.last().label == label) continue
result.add(Letter(label, section.position))
}
// The search field is the list's first row in all but name, and scrolls away with it.
// A magnifier above the letters says it is there and jumps back to it.
if (result.isNotEmpty() && hasSearchField()) result.add(0, Letter("", 0, isSearch = true))
return result
}
private fun hasSearchField(): Boolean =
!PreferenceManager2.getInstance(context).hideAppDrawerSearchBar.firstCached()
private fun refreshIfChanged() {
// Any new layout may have moved the list; keep the search field riding on it.
FlexeraDrawer.moveSearchFieldWithList(appsView)
val searching = appsView.isSearching
val fresh = readLetters()
if (fresh != letters || searching != wasSearching) {
@@ -161,7 +235,8 @@ class WaveAlphabetView(
appsView.activeRecyclerView?.let { rv ->
getLocationInWindow(location)
rv.getLocationInWindow(otherLocation)
top = (otherLocation[1] - location[1]).toFloat().coerceAtLeast(0f)
// The list's rows start below its top padding, which holds the search field.
top = (otherLocation[1] - location[1] + rv.paddingTop).toFloat().coerceAtLeast(0f)
}
val bottomInset = rootWindowInsets?.systemWindowInsetBottom ?: 0
val availableTop = top + verticalMargin
@@ -176,8 +251,15 @@ 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 fullCount = FULL_ALPHABET + if (letters.first().isSearch) 1 else 0
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * fullCount))
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)
@@ -187,16 +269,9 @@ class WaveAlphabetView(
restingLetterSize = min(letterSize, spacing * MAX_LETTER_SHARE_OF_SLOT)
}
// The strip sits where Android's back swipe starts. A home app may reserve the whole
// edge, so the scrub never turns into Back.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (onLeft) {
exclusionRect.set(0, stripTop.toInt(), touchWidth.toInt(), stripBottom.toInt())
} else {
exclusionRect.set((width - touchWidth).toInt(), stripTop.toInt(), width, stripBottom.toInt())
}
systemGestureExclusionRects = listOf(exclusionRect)
}
// The strip does not reserve the edge for itself: Android's Back swipe starts there,
// and the owner closes the drawer with it. The two share the edge by direction, see
// onTouchEvent.
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -212,6 +287,7 @@ class WaveAlphabetView(
override fun onDetachedFromWindow() {
viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
stopWaitingForBack()
pressAnimator?.cancel()
super.onDetachedFromWindow()
}
@@ -225,29 +301,36 @@ class WaveAlphabetView(
// Where the letters rest, and which way "away from the edge" points.
val baseX = if (onLeft) edgeInset else width - edgeInset
val inward = if (onLeft) 1f else -1f
// The wave's peak sits a fixed gap before the finger, so it reaches further out the
// further the finger is from the edge.
// The wave's peak sits a fixed gap beyond the finger, on the side away from the edge,
// which is the side the thumb leaves uncovered. So the wave reaches further out the
// further the finger is from the edge, up to nearly the whole width of the screen.
val fingerDistance = (fingerX - baseX) * inward
val maxReach = width * 0.7f
val maxReach = max(minReach + 1f, width - edgeInset - farMargin)
val fullReach = (fingerDistance + fingerGap).coerceIn(minReach, maxReach)
val reach = fullReach * press
// The wave is wide: several letters on each side of the finger ride it, so they curve
// around the whole thumb and stay readable above and below it. A wave that reaches
// far out is wider still, which keeps its shape.
val spread = max(waveWidth, fullReach * WAVE_WIDTH_SHARE_OF_REACH)
// Darken the list under the wave where its letters cross icons and names. From the
// right edge that only happens once the wave reaches well into the screen, as it does
// after swipe up and hold; from the left edge the icons sit right beside the strip.
// Darken the list a little while scrubbing, so the eye goes to the letters, and more
// where the wave lies over icons and names: from the right edge that is once it reaches
// well into the screen, as after swipe up and hold; from the left edge it is at once.
if (press > 0f) {
val overList = if (onLeft) 1f else ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
val reachShare = ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
val overList = if (onLeft) 1f else SCRUB_DIM_AT_EDGE + (1f - SCRUB_DIM_AT_EDGE) * reachShare
val dim = (SCRUB_DIM * press * overList * 255).toInt()
if (dim > 0) canvas.drawColor(Color.argb(dim, 0, 0, 0))
}
for (i in letters.indices) {
val y = letterYs[i]
val nearness = if (press > 0f) bell((y - fingerY) / waveWidth) else 0f
val nearness = if (press > 0f) bell((y - fingerY) / spread) else 0f
val isSelected = i == selected && press > 0f
val scale = 1f + (if (isSelected) SELECTED_GROWTH else NEAR_GROWTH) * nearness * press
paint.textSize = restingLetterSize * scale
// Letters keep their size in the wave, so close neighbours never collide; the
// selected one is bold and a touch larger. Its large copy is in the bubble.
paint.textSize = restingLetterSize * (1f + (if (isSelected) SELECTED_GROWTH * press else 0f))
paint.typeface = if (isSelected) boldTypeface else Typeface.DEFAULT
paint.color = if (isSelected) {
activeColor
@@ -257,9 +340,49 @@ class WaveAlphabetView(
val x = baseX + inward * reach * nearness
// Centre the glyph on the letter's line.
val baseline = y - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(letters[i].label, x, baseline, paint)
if (letters[i].isSearch) {
drawSearchMark(canvas, x, y, paint.textSize * SEARCH_MARK_IN_LETTER_SIZES, paint.color, true)
} else {
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
if (letters[selected].isSearch) {
drawSearchMark(canvas, cx, fingerY, bubbleTextPaint.textSize, bubbleTextPaint.color, false)
} else {
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
}
}
}
/**
* Draws a magnifier, the mark for the search field, [size] across and centred on
* ([cx], [cy]): a ring with a short handle. Drawn by hand so that its line weight
* matches letters of any size.
*/
private fun drawSearchMark(canvas: Canvas, cx: Float, cy: Float, size: Float, color: Int, shadow: Boolean) {
val stroke = if (shadow) markPaint else plainMarkPaint
stroke.color = color
stroke.strokeWidth = size * 0.12f
val radius = size * 0.29f
val ringX = cx - size * 0.09f
val ringY = cy - size * 0.09f
canvas.drawCircle(ringX, ringY, radius, stroke)
val rim = radius * 0.7071f
val handle = size * 0.27f
canvas.drawLine(ringX + rim, ringY + rim, ringX + rim + handle, ringY + rim + handle, stroke)
}
private fun bell(distance: Float): Float = exp(-distance * distance)
@@ -274,13 +397,29 @@ class WaveAlphabetView(
* 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 {
fun isOnStripBand(x: Float, y: Float): Boolean = isOnBand(x, y, touchWidth)
/** The same for the home screen, where the band is narrower because nothing shows it. */
fun isOnHomeBand(x: Float, y: Float): Boolean = isOnBand(x, y, homeTouchWidth)
private fun isOnBand(x: Float, y: Float, bandWidth: Float): Boolean {
if (layoutDirty) computeLayout()
if (letters.isEmpty()) return false
val onEdge = if (onLeft) x <= touchWidth else x >= width - touchWidth
val onEdge = if (onLeft) x <= bandWidth else x >= width - bandWidth
return onEdge && y >= stripTop - verticalMargin && y <= stripBottom + verticalMargin
}
/**
* True when a touch at [x] starts on the strip of edge where Android may begin its Back
* swipe. With three-button navigation there is no such strip.
*/
private fun isInBackSwipeZone(x: Float): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false
@Suppress("DEPRECATION")
val insets = rootWindowInsets?.systemGestureInsets ?: return false
return if (onLeft) x < insets.left else x > width - insets.right
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
@@ -288,16 +427,50 @@ class WaveAlphabetView(
if (!isOnStrip(event.x, event.y)) return false
// Keep the drawer from scrolling or closing under the thumb.
parent?.requestDisallowInterceptTouchEvent(true)
beginScrub(event.x, event.y)
downX = event.x
downY = event.y
lastX = event.x
lastY = event.y
if (isInBackSwipeZone(event.x)) {
// This may be the start of Android's Back swipe, which moves sideways.
// Show nothing until the touch says which it is: a move up or down or a
// short rest is a scrub, a move sideways is left to Android.
waitingForBack = true
postDelayed(scrubAfterRest, REST_BEFORE_SCRUB_MS)
} else {
beginScrub(event.x, event.y)
}
return true
}
MotionEvent.ACTION_MOVE -> {
if (!scrubbing) return false
moveScrub(event.x, event.y)
lastX = event.x
lastY = event.y
if (waitingForBack) {
val dx = abs(event.x - downX)
val dy = abs(event.y - downY)
if (dy > touchSlop && dy >= dx) {
stopWaitingForBack()
beginScrub(event.x, event.y)
} else if (dx > touchSlop) {
// Sideways: Android takes the touch from here on, and with it Back.
stopWaitingForBack()
}
} else if (scrubbing) {
moveScrub(event.x, event.y)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!scrubbing) return false
MotionEvent.ACTION_UP -> {
if (waitingForBack) {
// Down and up without moving: a tap on a letter.
stopWaitingForBack()
beginScrub(event.x, event.y)
}
endScrub()
return true
}
MotionEvent.ACTION_CANCEL -> {
stopWaitingForBack()
endScrub()
return true
}
@@ -305,6 +478,11 @@ class WaveAlphabetView(
return scrubbing
}
private fun stopWaitingForBack() {
waitingForBack = false
removeCallbacks(scrubAfterRest)
}
/** Starts a scrub with the finger at ([x], [y]) in this view's coordinates. */
fun beginScrub(x: Float, y: Float) {
refreshIfChanged()
@@ -316,6 +494,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
@@ -327,7 +526,7 @@ class WaveAlphabetView(
if (index != selected) {
selected = index
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
scrollListTo(letters[index].adapterPosition)
scrollListTo(index)
}
invalidate()
}
@@ -341,10 +540,31 @@ class WaveAlphabetView(
val isScrubbing: Boolean get() = scrubbing
private fun scrollListTo(adapterPosition: Int) {
/**
* Brings the group of the letter at [index] to the middle of the screen, where the thumb
* is, instead of to the top: the group's middle on the list's middle, though a long group
* keeps its header in view. Near either end of the list the list simply stops at its end.
* The magnifier goes to the very top, where the search field is.
*/
private fun scrollListTo(index: Int) {
val rv = appsView.activeRecyclerView ?: return
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
rv.stopScroll()
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(adapterPosition, 0)
val letter = letters[index]
if (letter.isSearch) {
layoutManager.scrollToPositionWithOffset(0, 0)
return
}
val groupEnd = letters.getOrNull(index + 1)?.adapterPosition
?: rv.adapter?.itemCount
?: (letter.adapterPosition + 2)
val rows = (groupEnd - letter.adapterPosition - 1).coerceAtLeast(1)
val groupHeight = headerHeight + rows * FlexeraDrawer.rowHeight(context)
val top = max(rv.height * GROUP_HIGHEST_TOP, rv.height * GROUP_MIDDLE - groupHeight / 2f)
layoutManager.scrollToPositionWithOffset(
letter.adapterPosition,
(top - rv.paddingTop).toInt().coerceAtLeast(0),
)
}
private fun animatePress(target: Float, durationMs: Long) {
@@ -405,7 +625,7 @@ class WaveAlphabetView(
private fun jumpTo(index: Int) {
val rv = appsView.activeRecyclerView ?: return
scrollListTo(letters[index].adapterPosition)
scrollListTo(index)
rv.doOnNextLayout {
chosenIndex = index
chosenAtPosition = firstVisiblePosition()
@@ -430,7 +650,9 @@ class WaveAlphabetView(
if (!stripVisible()) return
val index = currentIndex()
node.stateDescription = letters[index].label
node.stateDescription = letters[index].let {
if (it.isSearch) context.getString(R.string.flexera_alphabet_search) else it.label
}
node.setRangeInfo(
AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_INT,
@@ -459,18 +681,42 @@ class WaveAlphabetView(
}
private companion object {
/** How much larger than its resting size the selected letter grows. */
const val SELECTED_GROWTH = 1.5f
/** Where a chosen letter's group is centred, and the highest its header may go, as shares of the list's height. */
const val GROUP_MIDDLE = 0.5f
const val GROUP_HIGHEST_TOP = 0.22f
/** How much its neighbours grow, tapering with distance from the finger. */
const val NEAR_GROWTH = 0.5f
/** The magnifier's size next to a letter of the same text size. */
const val SEARCH_MARK_IN_LETTER_SIZES = 1.1f
/** 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 larger than its resting size the selected letter is in the wave itself. */
const val SELECTED_GROWTH = 0.25f
/** The bubble: a near-white disc, its letter near-black, a soft shadow under it. */
const val BUBBLE_COLOR = 0xFFF1F1F4.toInt()
const val BUBBLE_TEXT_COLOR = 0xFF17181C.toInt()
const val BUBBLE_SHADOW_COLOR = 0x66000000
/** The wave's spread up and down the edge, as a share of how far it reaches out. */
const val WAVE_WIDTH_SHARE_OF_REACH = 0.36f
/** How dark the list gets under a wave that lies fully over it (0 = unchanged, 1 = black). */
const val SCRUB_DIM = 0.55f
/** The share of [SCRUB_DIM] applied when the wave stays at the right edge, clear of the list. */
const val SCRUB_DIM_AT_EDGE = 0.35f
/** The tallest a resting letter may be, as a share of the vertical room it has. */
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
/**
* How long a touch on the Back strip may rest before it counts as a scrub. Long
* enough for a Back swipe to have started moving, short enough not to be felt.
*/
const val REST_BEFORE_SCRUB_MS = 130L
const val PRESS_IN_MS = 110L
const val PRESS_OUT_MS = 220L
+167
View File
@@ -0,0 +1,167 @@
package app.flexera.home
import android.app.ActivityOptions
import android.content.Context
import android.content.pm.LauncherApps
import android.os.Build
import android.util.Log
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import app.lawnchair.LawnchairLauncher
import app.lawnchair.gestures.config.GestureHandlerConfig
import app.lawnchair.gestures.handlers.OpenAppTarget
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.Launcher
import com.android.launcher3.LauncherState
import com.android.launcher3.util.EdgeEffectCompat
/**
* Quick launch: a swipe on from the first home screen page, towards the page that is not there,
* opens an app the owner has chosen (or does another of the launcher's gesture actions); the
* same past the last page.
*
* The home screen already answers such a swipe by stretching and springing back. That stretch is
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
*/
object QuickLaunch {
private const val TAG = "QuickLaunch"
/** How far the finger has to pull past the page, as a share of the screen's width. */
private const val ARM_DISTANCE = 0.14f
/** A flick counts from this much pull, at [FLICK_DP_PER_SECOND] or faster. */
private const val FLICK_DISTANCE = 0.02f
private const val FLICK_DP_PER_SECOND = 600f
/**
* The edge effect for one side of the home screen pages. [atLeftEdge] says which side of
* the screen; which page that is the end of depends on the reading direction ([rtl]).
*/
@JvmStatic
fun edgeEffect(workspace: View, launcher: Launcher, atLeftEdge: Boolean, rtl: Boolean): EdgeEffectCompat =
Effect(workspace, launcher, firstPage = atLeftEdge != rtl, atLeftEdge = atLeftEdge)
/** What the owner has chosen for a swipe past the first or the last page. */
private fun choice(context: Context, firstPage: Boolean): GestureHandlerConfig {
val prefs = PreferenceManager2.getInstance(context)
return if (firstPage) {
prefs.firstPageSwipeGestureHandler.firstCached()
} else {
prefs.lastPageSwipeGestureHandler.firstCached()
}
}
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
val choice = choice(launcher, firstPage)
if (choice is GestureHandlerConfig.NoOp) return
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
if (target is OpenAppTarget.App) {
// The app opens out of the screen edge the finger pulled from, as near to a page
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
// the launcher's own making only for the phone's built-in launcher.
val root = launcher.rootView
val options = ActivityOptions.makeClipRevealAnimation(
root,
if (fromLeft) 0 else root.width - 1,
0,
1,
root.height,
)
try {
launcher.getSystemService(LauncherApps::class.java)
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
} catch (e: RuntimeException) {
// The chosen app is gone or cannot be started; nothing to open.
Log.w(TAG, "The quick launch app could not be opened", e)
}
return
}
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
}
private class Effect(
private val workspace: View,
private val launcher: Launcher,
private val firstPage: Boolean,
private val atLeftEdge: Boolean,
) : EdgeEffectCompat(workspace.context) {
/** How far the finger has pulled in this touch, as a share of the screen's width. */
private var pulled = 0f
/** Whether there is anything to do on this side; looked up when a pull begins. */
private var active = false
private var armed = false
private var flicked = false
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
if (pulled == 0f && deltaDistance > 0f) {
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
launcher.isInState(LauncherState.NORMAL) &&
!launcher.dragController.isDragging
}
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
val nowArmed = active && pulled >= ARM_DISTANCE
if (nowArmed != armed) {
armed = nowArmed
// A tick when the pull is far enough, and again when it is taken back.
tick()
}
return consumed
}
override fun onFlingVelocity(velocity: Int) {
// A pull at the left side is a finger moving right, which is a positive speed.
val outward = if (atLeftEdge) velocity else -velocity
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
}
override fun onRelease(ev: MotionEvent) {
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
val fire = lifted && active && (armed || flicked)
val tickNow = fire && !armed
reset()
super.onRelease(ev)
if (fire) {
if (tickNow) tick()
// After the page has finished handling this touch.
workspace.post { launch(launcher, firstPage, fromLeft = atLeftEdge) }
}
}
override fun onRelease() {
reset()
super.onRelease()
}
override fun finish() {
reset()
super.finish()
}
private fun reset() {
pulled = 0f
active = false
armed = false
flicked = false
}
private fun tick() {
val feedback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE
} else {
HapticFeedbackConstants.CLOCK_TICK
}
// The pages themselves have haptics switched off; the window's root has not.
launcher.rootView.performHapticFeedback(feedback)
}
}
}
+150 -2
View File
@@ -1,9 +1,14 @@
package app.flexera.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.provider.Settings
import android.text.format.Formatter
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -17,6 +22,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 +36,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
@@ -40,6 +51,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.flexera.update.CheckProblem
import app.flexera.update.FlexeraRelease
import app.flexera.update.FlexeraUpdater
import app.flexera.update.UpdateFailure
@@ -54,6 +66,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 +77,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 +199,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>(
@@ -226,6 +250,21 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
@Composable
private fun UpdateStatusRow(state: UpdateState) {
val context = LocalContext.current
// Android 17 asks the owner before an app may reach devices on the local network, and an
// update server at home is one. The answer starts the check again; a "no" that Android no
// longer asks about leads to the app's page in the phone's settings instead.
val askForLocalNetwork = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
val activity = context.findActivity()
if (granted || activity == null ||
activity.shouldShowRequestPermissionRationale(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
) {
FlexeraUpdater.check(context)
} else {
openAppSettings(context)
}
}
when (state) {
UpdateState.Idle -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
@@ -275,8 +314,14 @@ private fun UpdateStatusRow(state: UpdateState) {
if (release == null) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_check_failed),
onClick = { FlexeraUpdater.check(context) },
subtitle = checkFailureText(state),
onClick = {
if (state.problem == CheckProblem.LOCAL_NETWORK) {
askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
} else {
FlexeraUpdater.check(context)
}
},
)
} else {
ClickablePreference(
@@ -295,6 +340,91 @@ 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),
)
}
}
}
}
}
/**
* Says why a check for updates failed and what to do about it, then the error's own words, which
* are what someone looking for the cause will ask for.
*/
@Composable
private fun checkFailureText(state: UpdateState.Failed): String {
val host = FlexeraUpdater.SERVER_HOST
val reason = when (state.problem) {
CheckProblem.NO_PERMISSION -> stringResource(id = R.string.flexera_update_failed_no_permission)
CheckProblem.SERVER_NOT_FOUND -> stringResource(id = R.string.flexera_update_failed_not_found, host)
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
CheckProblem.LOCAL_NETWORK -> stringResource(id = R.string.flexera_update_failed_local_network, host)
CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
}
val detail = state.detail ?: return reason
return reason + "\n" + stringResource(id = R.string.flexera_update_failed_detail, detail)
}
@Composable
private fun downloadSizeText(context: Context, release: FlexeraRelease): String =
if (release.apkSize > 0) {
@@ -341,3 +471,21 @@ private fun openNotificationSettings(context: Context) {
context.startActivity(intent)
}
}
/** Opens this app's page in the phone's settings, where its permissions are. */
private fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}
/** The activity this context belongs to, if it belongs to one. */
private fun Context.findActivity(): Activity? {
var current: Context? = this
while (current != null) {
if (current is Activity) return current
current = (current as? ContextWrapper)?.baseContext
}
return null
}
@@ -4,11 +4,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.flexera.drawer.FlexeraDrawer
import app.lawnchair.preferences.getAdapter
import app.lawnchair.preferences.preferenceManager
import app.lawnchair.preferences2.preferenceManager2
import app.lawnchair.ui.preferences.LocalIsExpandedScreen
import app.lawnchair.ui.preferences.components.NavigationActionPreference
import app.lawnchair.ui.preferences.components.controls.ListPreference
import app.lawnchair.ui.preferences.components.controls.ListPreferenceEntry
import app.lawnchair.ui.preferences.components.controls.SwitchPreference
import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
@@ -49,6 +52,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),
@@ -70,6 +78,18 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
// 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.
PreferenceGroup(heading = stringResource(id = R.string.style)) {
ListPreference(
adapter = prefs.flexeraDrawerRowSize.getAdapter(),
entries = listOf(
ListPreferenceEntry(FlexeraDrawer.RowSize.COMPACT.key) {
stringResource(id = R.string.flexera_row_size_compact)
},
ListPreferenceEntry(FlexeraDrawer.RowSize.COMFY.key) {
stringResource(id = R.string.flexera_row_size_comfy)
},
),
label = stringResource(id = R.string.flexera_row_size),
)
SwitchPreference(
label = stringResource(id = R.string.pref_all_apps_search_bar_background),
adapter = prefs2.appDrawerSearchBarBackground.getAdapter(),
@@ -9,7 +9,9 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
@@ -23,7 +25,11 @@ import com.android.launcher3.R
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.URL
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -31,6 +37,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
/** One published version of Flexera Launcher. */
@@ -56,6 +63,39 @@ enum class UpdateFailure {
WRONG_FILE,
}
/**
* Why a check for updates failed, as far as the phone can tell. The About screen says it in
* words, because "could not reach the server" alone leaves the owner guessing between the
* connection, a VPN, the server and the phone's own settings.
*/
enum class CheckProblem {
/** Android does not let this app use the network (a per-app switch on some systems). */
NO_PERMISSION,
/** The server's name could not be looked up. */
SERVER_NOT_FOUND,
/** The server was found but did not answer in time, or refused the connection. */
NO_ANSWER,
/**
* The server's address is on the network the phone itself is on (a server at home, reached
* over the home Wi-Fi), and Android has not been told to let this app reach devices there.
* Since Android 17 that takes a permission of its own; without it the connection is
* dropped without a word and looks like a server that does not answer.
*/
LOCAL_NETWORK,
/** The encrypted connection could not be set up. */
NOT_SECURE,
/** The server answered, with an error. */
SERVER_ERROR,
/** The server answered with something that is not a release. */
BAD_ANSWER,
}
/** Where the updater currently stands. The About screen shows one row per state. */
sealed interface UpdateState {
/** Nothing has been checked since the launcher started. */
@@ -72,8 +112,17 @@ sealed interface UpdateState {
data class ReadyToInstall(val release: FlexeraRelease, val file: File) : UpdateState
/** [release] is set when the failure happened after a newer version had been found. */
data class Failed(val failure: UpdateFailure, val release: FlexeraRelease? = null) : UpdateState
/**
* [release] is set when the failure happened after a newer version had been found. For a
* failed check, [problem] says why and [detail] carries the error's own words, for
* whoever has to find the cause.
*/
data class Failed(
val failure: UpdateFailure,
val release: FlexeraRelease? = null,
val problem: CheckProblem? = null,
val detail: String? = null,
) : UpdateState
}
/**
@@ -179,7 +228,12 @@ object FlexeraUpdater {
nextAutomaticCheckAt = System.currentTimeMillis() + RETRY_INTERVAL_MS
scope.launch {
val result = runCatching { fetchLatestRelease(app) }
val result = runCatching {
// Android drops a connection it does not allow without a word, and the check
// would wait out its time limit. Say at once what is needed instead.
if (blockedFromLocalNetwork(app)) throw LocalNetworkBlockedException()
fetchLatestRelease(app)
}
prefs(app).edit()
.putLong(KEY_LAST_ATTEMPT, System.currentTimeMillis())
.putBoolean(KEY_LAST_ATTEMPT_OK, result.isSuccess)
@@ -190,7 +244,7 @@ object FlexeraUpdater {
Log.w(TAG, "Update check failed", error)
// 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)
mutableState.value = if (automatic) before else failedCheck(app, error)
return@launch
}
@@ -302,13 +356,111 @@ object FlexeraUpdater {
private fun isBusy(): Boolean =
mutableState.value.let { it is UpdateState.Checking || it is UpdateState.Downloading }
/** The server's name without the scheme, for messages. */
@JvmField
val SERVER_HOST: String = SERVER.substringAfter("://")
private class NoNetworkPermissionException : IOException("This app has no network permission")
private class ServerErrorException(status: Int) : IOException("The server answered HTTP $status")
private class BadAnswerException(message: String) : IOException(message)
private class LocalNetworkBlockedException : IOException("Android does not let this app reach the local network")
/**
* The permission Android 17 asks of an app before it may reach devices on the local
* network. Named by its text, because older Android versions, which the app also runs on,
* do not know it.
*/
const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
/** The first Android version that keeps apps from the local network: Android 17. */
private const val LOCAL_NETWORK_SDK = 37
/**
* True when Android is keeping this app from the update server because the server is on
* the phone's own network and the owner has not allowed local network access. Looks the
* server's name up, so not for the main thread.
*/
private fun blockedFromLocalNetwork(context: Context): Boolean {
if (Build.VERSION.SDK_INT < LOCAL_NETWORK_SDK) return false
if (context.checkSelfPermission(LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED) return false
return try {
// Looking a name up is allowed without the permission, also at a resolver at home.
InetAddress.getAllByName(URL(SERVER).host).any(::isOnOwnNetwork)
} catch (e: IOException) {
false
} catch (e: SecurityException) {
false
}
}
/**
* True for an address that this phone reaches directly, without a router in between: one
* inside the range of one of its own network connections. That is what Android counts as
* the local network.
*/
private fun isOnOwnNetwork(address: InetAddress): Boolean {
if (address.isLoopbackAddress) return false
if (address.isLinkLocalAddress) return true
val target = address.address
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
for (network in interfaces) {
if (network.isLoopback || !network.isUp) continue
for (own in network.interfaceAddresses) {
val base = own.address?.address ?: continue
if (base.size != target.size) continue
var bits = own.networkPrefixLength.toInt()
if (bits <= 0) continue
var same = true
for (i in base.indices) {
if (bits <= 0) break
val mask = if (bits >= 8) 0xFF else (0xFF shl (8 - bits)) and 0xFF
if ((base[i].toInt() and mask) != (target[i].toInt() and mask)) {
same = false
break
}
bits -= 8
}
if (same) return true
}
}
return false
}
/** Turns the error of a failed check into a state that says what kind of failure it was. */
private fun failedCheck(context: Context, error: Throwable): UpdateState.Failed {
val kind = when (error) {
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
is LocalNetworkBlockedException -> CheckProblem.LOCAL_NETWORK
is UnknownHostException -> CheckProblem.SERVER_NOT_FOUND
is SSLException -> CheckProblem.NOT_SECURE
is ServerErrorException -> CheckProblem.SERVER_ERROR
is BadAnswerException, is JSONException -> CheckProblem.BAD_ANSWER
else -> CheckProblem.NO_ANSWER
}
// A server that did not answer may still be one that Android kept this app from, where
// the look before the check could not tell (the network changed in between, say).
val problem = if (kind == CheckProblem.NO_ANSWER && blockedFromLocalNetwork(context)) {
CheckProblem.LOCAL_NETWORK
} else {
kind
}
// Android's own error classes keep their names in a release build; this file's are
// renamed by the shrinker, and their messages say everything anyway.
val name = error.javaClass.name.takeIf { it.startsWith("java") || it.startsWith("android") || it.startsWith("org.json") }
?.substringAfterLast('.')
val detail = listOfNotNull(name, error.message).joinToString(": ").take(MAX_DETAIL_LENGTH)
return UpdateState.Failed(UpdateFailure.CHECK, problem = problem, detail = detail.ifEmpty { null })
}
private const val MAX_DETAIL_LENGTH = 200
/** 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)
try {
val status = connection.responseCode
if (status == HttpURLConnection.HTTP_NOT_FOUND) return null
if (status != HttpURLConnection.HTTP_OK) throw IOException("Update server answered HTTP $status")
if (status != HttpURLConnection.HTTP_OK) throw ServerErrorException(status)
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
val tag = json.getString("tag_name")
@@ -326,7 +478,7 @@ object FlexeraUpdater {
apkSize = asset.optLong("size", 0L),
)
}
throw IOException("Release $tag has no APK attached")
throw BadAnswerException("Release $tag has no APK attached")
} finally {
connection.disconnect()
}
@@ -373,7 +525,7 @@ object FlexeraUpdater {
}
private fun open(context: Context, url: String): HttpURLConnection {
if (!FlexeraNetwork.hasInternetPermission(context)) throw IOException("No INTERNET permission")
if (!FlexeraNetwork.hasInternetPermission(context)) throw NoNetworkPermissionException()
return (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
@@ -0,0 +1,143 @@
package app.flexera.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import com.android.launcher3.LauncherAppState
import com.android.launcher3.LauncherSettings.Favorites
import com.android.launcher3.R
import kotlin.math.min
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Lets the owner choose the pictures of a picture widget. It has no screen of its own: it opens
* Android's photo picker, copies what was chosen into the widget's storage and closes.
*
* Android starts it when the widget is put on the home screen and from the widget's edit
* button; the widget starts it itself when it has no pictures. The photo picker hands over
* only the pictures the owner picks, so the launcher needs no access to the gallery.
*/
class PictureWidgetConfigActivity : ComponentActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
private val picker = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(mostPictures()),
) { chosen -> onChosen(chosen) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appWidgetId = intent?.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
// Leaving without pictures means "no": a widget that was just being added is dropped.
setResult(RESULT_CANCELED, result())
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// After Android has restarted this screen, the picker is already open and answers here.
if (savedInstanceState != null) return
val widget = appWidgetId
lifecycleScope.launch {
// A widget that came with an import already has its pictures waiting.
val adopted = withContext(Dispatchers.IO) { adoptImported(widget) }
if (adopted) {
setResult(RESULT_OK, result())
finish()
} else {
picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}
}
/**
* Hands the widget the pictures an import kept for its place on the home screen, if this is
* such a widget and it has none yet. The launcher's database knows where the widget sits,
* and an import keeps its pictures under that place.
*/
private fun adoptImported(widget: Int): Boolean {
val context = applicationContext
if (!PictureWidgetStore.hasImports(context)) return false
if (PictureWidgetStore.pictures(context, widget).isNotEmpty()) return false
return try {
LauncherAppState.getInstance(context).model.modelDbController.query(
arrayOf(Favorites.SCREEN, Favorites.CELLX, Favorites.CELLY),
"${Favorites.APPWIDGET_ID} = ? AND ${Favorites.ITEM_TYPE} = ${Favorites.ITEM_TYPE_APPWIDGET} " +
"AND ${Favorites.CONTAINER} = ${Favorites.CONTAINER_DESKTOP}",
arrayOf(widget.toString()),
null,
).use { row ->
row.moveToFirst() &&
PictureWidgetStore.adoptImport(context, widget, row.getInt(0), row.getInt(1), row.getInt(2))
}
} catch (e: RuntimeException) {
Log.w(TAG, "Could not look for imported pictures", e)
false
}
}
private fun onChosen(chosen: List<Uri>) {
if (chosen.isEmpty()) {
finish()
return
}
// Copying a few large photos takes a moment.
setContentView(
FrameLayout(this).apply {
addView(
ProgressBar(context),
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER,
),
)
},
)
val widget = appWidgetId
lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) {
PictureWidgetStore.replace(applicationContext, widget, chosen)
}
if (saved > 0) {
setResult(RESULT_OK, result())
} else {
Toast.makeText(
this@PictureWidgetConfigActivity,
R.string.flexera_picture_widget_unreadable,
Toast.LENGTH_LONG,
).show()
}
finish()
}
}
private fun result() = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
private companion object {
const val TAG = "PictureWidgetConfig"
/** How many pictures the photo picker lets the owner tick. */
fun mostPictures(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
min(PictureWidgetStore.MAX_PICTURES, MediaStore.getPickImagesMaxLimit())
} else {
PictureWidgetStore.MAX_PICTURES
}
}
}
@@ -0,0 +1,49 @@
package app.flexera.widget
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.graphics.Rect
import android.view.View
import com.android.launcher3.BuildConfig
import com.android.launcher3.model.data.LauncherAppWidgetInfo
/**
* The launcher's own picture widget: one picture, or several that take turns.
*
* Android knows it as an ordinary app widget, so it is added, moved, resized and removed like
* any other. What it shows is not sent through Android, though: the launcher draws it itself
* with a [PictureWidgetView], which is what lets it line up with the icons around it. Its
* pictures live in [PictureWidgetStore].
*/
class PictureWidgetProvider : AppWidgetProvider() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
appWidgetIds.forEach { PictureWidgetStore.delete(context, it) }
}
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
for (i in oldWidgetIds.indices) {
val newId = newWidgetIds.getOrNull(i) ?: continue
PictureWidgetStore.move(context, oldWidgetIds[i], newId)
}
}
companion object {
@JvmField
val componentName = ComponentName(BuildConfig.APPLICATION_ID, PictureWidgetProvider::class.java.name)
private val noInset = Rect()
/**
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget
* the same ([standard]); the picture widget takes none, because it places its picture
* against the icons of its cells by itself.
*/
@JvmStatic
fun cellInset(child: View, standard: Rect): Rect {
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
return if (info.providerName == componentName) noInset else standard
}
}
}
@@ -0,0 +1,242 @@
package app.flexera.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.WorkerThread
import java.io.File
import java.util.Locale
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Where the picture widget keeps its pictures: copies in the app's own storage, one folder per
* widget on the home screen.
*
* Copies, because Android's photo picker lends a picture only for a while, and because a copy
* needs no permission to read the gallery later. Each copy is scaled down to what a widget can
* show, so a slideshow of large photos stays small.
*/
object PictureWidgetStore {
/** Told, on the main thread, that the pictures of a widget were replaced or removed. */
fun interface Listener {
fun onPicturesChanged(appWidgetId: Int)
}
private val listeners = CopyOnWriteArraySet<Listener>()
private val mainHandler = Handler(Looper.getMainLooper())
fun addListener(listener: Listener) {
listeners.add(listener)
}
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** The pictures of one widget, in the order they were chosen. Empty when it has none. */
fun pictures(context: Context, appWidgetId: Int): List<File> =
folder(context, appWidgetId).listFiles { file -> file.isFile }?.sortedBy { it.name }.orEmpty()
/**
* Gives a widget copies of [sources] in place of the pictures it had, and returns how many
* were saved. A source that cannot be read is left out; when none can be read the widget
* keeps what it had and the result is 0.
*/
@WorkerThread
fun replace(context: Context, appWidgetId: Int, sources: List<Uri>): Int {
val incoming = File(root(context), "$appWidgetId$INCOMING_SUFFIX")
var saved = saveCopies(context, incoming, sources)
if (saved == 0) return 0
val folder = folder(context, appWidgetId)
folder.deleteRecursively()
if (!incoming.renameTo(folder)) {
incoming.deleteRecursively()
saved = 0
}
notifyChanged(appWidgetId)
return saved
}
/**
* Keeps copies of [sources] for a picture widget that an import has put at a place on a
* home screen. Such a widget has no id yet: Android gives it one when the owner taps it to
* set it up, and [adoptImport] then hands it these pictures. Returns how many were saved.
*/
@WorkerThread
fun stageImport(context: Context, screen: Int, cellX: Int, cellY: Int, sources: List<Uri>): Int =
saveCopies(context, importFolder(context, screen, cellX, cellY), sources)
/** True while pictures of an import wait for their widgets. */
fun hasImports(context: Context): Boolean =
root(context).listFiles { file -> file.isDirectory && file.name.startsWith(IMPORT_PREFIX) }
?.isNotEmpty() == true
/** Forgets the pictures of an earlier import that were never claimed. */
fun clearImports(context: Context) {
root(context).listFiles { file -> file.isDirectory && file.name.startsWith(IMPORT_PREFIX) }
?.forEach { it.deleteRecursively() }
}
/**
* Gives a widget the pictures an import kept for the place it sits at. True if there were
* any; the widget then needs no pictures chosen for it.
*/
@WorkerThread
fun adoptImport(context: Context, appWidgetId: Int, screen: Int, cellX: Int, cellY: Int): Boolean {
val staged = importFolder(context, screen, cellX, cellY)
if (staged.listFiles { file -> file.isFile }.isNullOrEmpty()) return false
val folder = folder(context, appWidgetId)
folder.deleteRecursively()
if (!staged.renameTo(folder)) return false
notifyChanged(appWidgetId)
return true
}
/** Fills [into] with copies of [sources], replacing what it held. Returns how many were saved. */
private fun saveCopies(context: Context, into: File, sources: List<Uri>): Int {
into.deleteRecursively()
if (!into.mkdirs()) return 0
var saved = 0
for (source in sources.take(MAX_PICTURES)) {
val bitmap = try {
decode(context, source)
} catch (e: Exception) {
Log.w(TAG, "A chosen picture could not be read", e)
null
} ?: continue
// A picture with see-through parts keeps them; everything else is a photo.
val keepsAlpha = isSeeThrough(bitmap)
val name = String.format(Locale.ROOT, "%03d.%s", saved, if (keepsAlpha) "png" else "jpg")
val file = File(into, name)
val written = try {
file.outputStream().use { out ->
bitmap.compress(
if (keepsAlpha) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG,
JPEG_QUALITY,
out,
)
}
} catch (e: Exception) {
Log.w(TAG, "A chosen picture could not be saved", e)
false
}
bitmap.recycle()
if (written) saved++ else file.delete()
}
if (saved == 0) into.deleteRecursively()
return saved
}
/** Removes the pictures of a widget that left the home screen. */
fun delete(context: Context, appWidgetId: Int) {
folder(context, appWidgetId).deleteRecursively()
File(root(context), "$appWidgetId$INCOMING_SUFFIX").deleteRecursively()
notifyChanged(appWidgetId)
}
/** Hands the pictures of a widget to the id Android gave it after a restore. */
fun move(context: Context, fromAppWidgetId: Int, toAppWidgetId: Int) {
val from = folder(context, fromAppWidgetId)
if (!from.isDirectory || fromAppWidgetId == toAppWidgetId) return
val to = folder(context, toAppWidgetId)
to.deleteRecursively()
from.renameTo(to)
notifyChanged(toAppWidgetId)
}
/**
* Reads a stored picture for a frame of [width] by [height] pixels: at the smallest size
* that still covers the frame, so that no more memory is used than the screen can show.
*/
@WorkerThread
fun load(file: File, width: Int, height: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (bounds.outWidth / (sample * 2) >= width && bounds.outHeight / (sample * 2) >= height) {
sample *= 2
}
return BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })
}
/** Reads a chosen picture, upright and no larger than [MAX_EDGE] on its long side. */
private fun decode(context: Context, source: Uri): Bitmap? {
val resolver = context.contentResolver
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// ImageDecoder turns the picture the way the camera held it and knows the newer
// formats (HEIC, AVIF).
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, source)) { decoder, info, _ ->
// The copy is compressed right after, which needs the pixels in ordinary memory.
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
val longEdge = max(info.size.width, info.size.height)
if (longEdge > MAX_EDGE) {
val scale = MAX_EDGE.toFloat() / longEdge
decoder.setTargetSize(
max(1, (info.size.width * scale).roundToInt()),
max(1, (info.size.height * scale).roundToInt()),
)
}
}
}
// Android 8: no ImageDecoder. Photos taken sideways stay sideways there.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
while (max(bounds.outWidth, bounds.outHeight) / sample > MAX_EDGE) sample *= 2
val options = BitmapFactory.Options().apply { inSampleSize = sample }
return resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, options) }
}
/**
* True if some of the picture lets the background through. Many pictures can hold such
* parts without having any (every screenshot, for one), so the pixels are looked at.
*/
private fun isSeeThrough(bitmap: Bitmap): Boolean {
if (!bitmap.hasAlpha()) return false
val row = IntArray(bitmap.width)
for (y in 0 until bitmap.height) {
bitmap.getPixels(row, 0, bitmap.width, 0, y, bitmap.width, 1)
if (row.any { it ushr 24 != 0xFF }) return true
}
return false
}
private fun notifyChanged(appWidgetId: Int) {
mainHandler.post { listeners.forEach { it.onPicturesChanged(appWidgetId) } }
}
private fun root(context: Context) = File(context.filesDir, FOLDER)
private fun folder(context: Context, appWidgetId: Int) = File(root(context), appWidgetId.toString())
private fun importFolder(context: Context, screen: Int, cellX: Int, cellY: Int) =
File(root(context), "$IMPORT_PREFIX$screen-$cellX-$cellY")
private const val TAG = "PictureWidgetStore"
private const val FOLDER = "flexera_picture_widget"
private const val INCOMING_SUFFIX = ".incoming"
private const val IMPORT_PREFIX = "import-"
/** The most pictures one widget holds. */
const val MAX_PICTURES = 50
/**
* The long side of a stored picture, in pixels. A widget is never wider than the screen,
* and a picture is cropped to fill it, so this leaves room to crop on any phone.
*/
private const val MAX_EDGE = 2048
private const val JPEG_QUALITY = 90
}
@@ -0,0 +1,617 @@
package app.flexera.widget
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.graphics.Shader
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import app.lawnchair.icons.shape.CornerShapeCompat
import app.lawnchair.icons.shape.IconShape
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.R
import com.android.launcher3.ShortcutAndWidgetContainer
import com.android.launcher3.celllayout.CellLayoutLayoutParams
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
import com.android.launcher3.util.Executors
import com.android.launcher3.views.ActivityContext
import com.android.launcher3.widget.LauncherAppWidgetHostView
import com.android.launcher3.widget.PendingAppWidgetHostView
import java.io.File
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* What the picture widget ([PictureWidgetProvider]) looks like on the home screen: one picture
* cropped to fill a frame, or several that take turns every [INTERVAL_MS].
*
* The frame is made to look like a large icon. Its edges are the outer edges of the icons that
* would sit in the cells the widget covers, and its corners are the corners of the icon shape
* in use, so the picture lines up with the icons beside, above and below it. The view itself is
* larger than the frame: it fills the widget's cells and draws nothing outside the frame.
*
* In the list of widgets and while it is dragged from there, the same view shows a sample.
*/
class PictureWidgetView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs),
PictureWidgetStore.Listener {
/** A picture ready to draw. */
private class Slide(val bitmap: Bitmap) {
val shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
/** True while this is only a sample of the widget, with no pictures of its own. */
private var sample = true
private var pictures: List<File> = emptyList()
private var index = 0
private var shown: Slide? = null
// The picture that is fading in over the shown one, and its place among the pictures.
private var incoming: Slide? = null
private var incomingIndex = 0
private var fade = 0f
private var fadeAnimator: ValueAnimator? = null
// Pictures are read off the main thread. A result is used only if no newer read was started.
private var loadGeneration = 0
private var loading = false
/** The frame size the shown picture was read for; 0 before the first read. */
private var loadedWidth = 0
private var loadedHeight = 0
private var onScreen = false
private val frame = RectF()
private val shape = Path()
private val farHill = Path()
private val nearHill = Path()
private val sun = Path()
private val scratch = Path()
private val cropMatrix = Matrix()
private var hint: StaticLayout? = null
private var sampleShader: Shader? = null
private val picturePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val platePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = PLATE_COLOR }
private val sceneryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = SCENERY_COLOR }
private val hintPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = HINT_COLOR
textSize = resources.getDimension(R.dimen.flexera_picture_widget_hint_text_size)
}
private val advance = Runnable { showNext() }
private val reloadForSize = Runnable { reload() }
// The icon shape, as corners that can be put on a rectangle of any size. Null for the few
// shapes that are one fixed outline; those get plain round corners.
private val cornerShape: IconShape.CornerBased? by lazy(LazyThreadSafetyMode.NONE) {
when (val iconShape = currentIconShape()) {
is IconShape.SystemBased -> iconShape.findNearestShape()
is IconShape.CornerBased -> iconShape
else -> null
}
}
init {
setWillNotDraw(false)
// Launcher3 rounds the corners of every widget that does not round its own. This id
// together with clipToOutline is how a widget says that it does (see
// RoundedCornerEnforcement); without it the picture's corners would be cut twice.
id = android.R.id.background
clipToOutline = true
}
/** Tells the view which widget it shows. Ids that Android never hands out mean a sample. */
fun bind(appWidgetId: Int) {
this.appWidgetId = appWidgetId
sample = appWidgetId <= 0
if (sample) {
// In the list of widgets a tap belongs to the list.
setOnClickListener(null)
isClickable = false
} else {
setOnClickListener { onTap() }
}
index = 0
// What the frame holds depends on whether this is a sample.
frame.setEmpty()
if (isLaidOut) updateFrame()
if (isAttachedToWindow) reload()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
PictureWidgetStore.addListener(this)
reload()
}
override fun onDetachedFromWindow() {
PictureWidgetStore.removeListener(this)
fadeAnimator?.cancel()
removeCallbacks(advance)
removeCallbacks(reloadForSize)
// Whatever is still being read is no longer wanted.
loadGeneration++
loading = false
super.onDetachedFromWindow()
}
override fun onVisibilityAggregated(isVisible: Boolean) {
super.onVisibilityAggregated(isVisible)
// The pictures only take turns while the home screen can be seen.
onScreen = isVisible
if (isVisible) scheduleNext() else removeCallbacks(advance)
}
override fun onPicturesChanged(appWidgetId: Int) {
if (appWidgetId != this.appWidgetId || sample) return
index = 0
reload()
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
updateFrame()
if (!isAttachedToWindow || frame.isEmpty || pictures.isEmpty()) return
val width = frame.width().roundToInt()
val height = frame.height().roundToInt()
if (width == loadedWidth && height == loadedHeight) return
removeCallbacks(reloadForSize)
if (loadedWidth == 0) {
// The first time the size is known.
post(reloadForSize)
} else {
// The widget is being resized: read the picture anew once the size has settled.
postDelayed(reloadForSize, RESIZE_SETTLE_MS)
}
}
/** Reads the widget's pictures anew and shows the one whose turn it is. */
private fun reload() {
fadeAnimator?.cancel()
removeCallbacks(advance)
removeCallbacks(reloadForSize)
incoming = null
pictures = if (sample) emptyList() else PictureWidgetStore.pictures(context, appWidgetId)
updateDescription()
if (pictures.isEmpty()) {
loadGeneration++
loading = false
shown = null
loadedWidth = 0
loadedHeight = 0
invalidate()
return
}
if (frame.isEmpty) {
// Not laid out yet; onLayout comes back here.
loadedWidth = 0
loadedHeight = 0
return
}
index = index.coerceIn(0, pictures.lastIndex)
loadedWidth = frame.width().roundToInt()
loadedHeight = frame.height().roundToInt()
load(index) { slide ->
shown = slide
invalidate()
scheduleNext()
}
}
private fun load(position: Int, then: (Slide?) -> Unit) {
val file = pictures.getOrNull(position)
if (file == null) {
then(null)
return
}
val width = max(1, frame.width().roundToInt())
val height = max(1, frame.height().roundToInt())
val generation = ++loadGeneration
loading = true
Executors.THREAD_POOL_EXECUTOR.execute {
val bitmap = try {
PictureWidgetStore.load(file, width, height)
} catch (e: Exception) {
Log.w(TAG, "A picture could not be read", e)
null
} catch (e: OutOfMemoryError) {
Log.w(TAG, "A picture was too large to read", e)
null
}
Executors.MAIN_EXECUTOR.execute {
if (generation == loadGeneration) {
loading = false
then(bitmap?.let(::Slide))
}
}
}
}
private fun scheduleNext() {
removeCallbacks(advance)
if (onScreen && !sample && pictures.size > 1) postDelayed(advance, INTERVAL_MS)
}
private fun showNext() {
if (pictures.size < 2 || loading || incoming != null) return
val next = (index + 1) % pictures.size
load(next) { slide ->
if (slide == null) {
// A picture that cannot be read is passed over.
index = next
scheduleNext()
} else {
incoming = slide
incomingIndex = next
startFade()
}
}
}
private fun startFade() {
if (!ValueAnimator.areAnimatorsEnabled() || !isShown || shown == null) {
finishFade()
return
}
fade = 0f
fadeAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = FADE_MS
interpolator = LinearInterpolator()
addUpdateListener {
fade = it.animatedValue as Float
invalidate()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) = finishFade()
})
start()
}
}
private fun finishFade() {
fadeAnimator = null
val slide = incoming ?: return
shown = slide
index = incomingIndex
incoming = null
fade = 0f
updateDescription()
invalidate()
scheduleNext()
}
private fun onTap() {
when {
// Nothing to show yet, as after a restore on a new phone: ask for pictures.
pictures.isEmpty() -> choosePictures()
// A tap does not wait for the picture's turn to end.
pictures.size > 1 -> {
removeCallbacks(advance)
showNext()
}
}
}
private fun choosePictures() {
val intent = Intent(context, PictureWidgetConfigActivity::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
var activity: Context? = context
while (activity != null && activity !is Activity) {
activity = (activity as? ContextWrapper)?.baseContext
}
try {
if (activity != null) {
activity.startActivity(intent)
} else {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
} catch (e: RuntimeException) {
Log.w(TAG, "The picture chooser could not be opened", e)
}
}
private fun updateDescription() {
contentDescription = when {
sample -> resources.getString(R.string.flexera_picture_widget)
pictures.isEmpty() -> resources.getString(R.string.flexera_picture_widget_empty)
pictures.size == 1 -> resources.getString(R.string.flexera_picture_widget)
else -> resources.getString(
R.string.flexera_picture_widget_position,
index + 1,
pictures.size,
)
}
}
/**
* Works out where the picture goes inside this view. On the home screen that is the box
* around the icons of the cells the widget covers; anywhere else it is the whole view.
*/
private fun updateFrame() {
var left = 0f
var top = 0f
var right = width.toFloat()
var bottom = height.toFloat()
var corner = min(width, height) * LOOSE_CORNER_SHARE
var iconSize = min(width, height).toFloat()
val host = parent as? View
val lp = host?.layoutParams as? CellLayoutLayoutParams
val container = host?.parent as? ShortcutAndWidgetContainer
val profile = activityContext()?.deviceProfile
if (host != null && lp != null && container != null && profile != null &&
lp.cellHSpan > 0 && lp.cellVSpan > 0
) {
// The cells the widget covers, measured from the corner of the host view. Undoes
// what CellLayoutLayoutParams.setup took off them.
val inset = PictureWidgetProvider.cellInset(host, profile.widgetPadding)
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
val spanTop = -(lp.topMargin + inset.top).toFloat()
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
val gap = profile.cellLayoutBorderSpacePx
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
// Where an icon sits in its cell: centred sideways and a set distance below the
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
val icon = profile.iconSizePx.toFloat()
val iconLeft = (cellWidth - icon) / 2f
val iconTop = if (profile.cellYPaddingPx >= 0) {
profile.cellYPaddingPx.toFloat()
} else {
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
}
// What is seen of an icon is a little smaller than the square it is drawn in; the
// rest is room for its shadow.
val visible = icon * ICON_VISIBLE_AREA_FACTOR
val air = (icon - visible) / 2f
val l = spanLeft + iconLeft + air - getLeft()
val r = spanLeft + spanWidth - iconLeft - air - getLeft()
val t = spanTop + iconTop + air - getTop()
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - getTop()
if (r - l >= 1f && b - t >= 1f) {
left = l
top = t
right = r
bottom = b
corner = visible / 2f
iconSize = icon
}
}
corner = min(corner, min(right - left, bottom - top) / 2f)
if (left == frame.left && top == frame.top && right == frame.right && bottom == frame.bottom) return
frame.set(left, top, right, bottom)
shape.reset()
val corners = cornerShape
if (corners != null) {
CornerShapeCompat.addToPath(corners, shape, left, top, right, bottom, size = corner)
} else {
val radius = corner * currentIconShape().windowTransitionRadius
shape.addRoundRect(frame, radius, radius, Path.Direction.CW)
}
shape.close()
// The same soft shadow an icon has (ShadowGenerator), so the picture sits on the
// wallpaper the way the icons do.
platePaint.setShadowLayer(
iconSize * SHADOW_BLUR_SHARE,
0f,
iconSize * SHADOW_DROP_SHARE,
SHADOW_COLOR,
)
buildSample()
invalidate()
}
/** The drawing shown in place of a picture: a sun over two hills, and a hint when empty. */
private fun buildSample() {
val width = frame.width()
val height = frame.height()
val unit = min(width, height)
scratch.reset()
scratch.moveTo(frame.left + width * 0.28f, frame.bottom)
scratch.quadTo(frame.left + width * 0.66f, frame.bottom - unit * 0.80f, frame.right + width * 0.10f, frame.bottom)
scratch.close()
farHill.op(scratch, shape, Path.Op.INTERSECT)
scratch.reset()
scratch.moveTo(frame.left - width * 0.10f, frame.bottom)
scratch.quadTo(frame.left + width * 0.30f, frame.bottom - unit * 0.62f, frame.left + width * 0.74f, frame.bottom)
scratch.close()
nearHill.op(scratch, shape, Path.Op.INTERSECT)
scratch.reset()
scratch.addCircle(frame.right - unit * 0.30f, frame.top + unit * 0.30f, unit * 0.10f, Path.Direction.CW)
sun.op(scratch, shape, Path.Op.INTERSECT)
sampleShader = LinearGradient(
frame.left,
frame.top,
frame.right,
frame.bottom,
SAMPLE_COLOR_START,
SAMPLE_COLOR_END,
Shader.TileMode.CLAMP,
)
hint = null
val hintWidth = (width - 2 * resources.getDimension(R.dimen.flexera_picture_widget_hint_margin)).toInt()
if (!sample && hintWidth > 0) {
val text = resources.getString(R.string.flexera_picture_widget_empty)
val layout = StaticLayout.Builder.obtain(text, 0, text.length, hintPaint, hintWidth)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.build()
// Too small a widget for the words gets the drawing alone.
val fits = layout.lineCount <= HINT_MAX_LINES && layout.height <= height * HINT_MAX_HEIGHT_SHARE
if (fits) hint = layout
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (frame.isEmpty) return
val current = shown
if (current == null) {
drawSample(canvas)
return
}
// The plate carries the shadow. A picture with see-through parts goes without, so
// that the wallpaper shows through it.
if (!current.bitmap.hasAlpha()) canvas.drawPath(shape, platePaint)
drawSlide(canvas, current, 255)
incoming?.let { drawSlide(canvas, it, (fade * 255).roundToInt()) }
}
private fun drawSlide(canvas: Canvas, slide: Slide, alpha: Int) {
val bitmap = slide.bitmap
// Fill the frame and crop what does not fit, keeping the middle of the picture.
val scale = max(frame.width() / bitmap.width, frame.height() / bitmap.height)
cropMatrix.setScale(scale, scale)
cropMatrix.postTranslate(
frame.left + (frame.width() - bitmap.width * scale) / 2f,
frame.top + (frame.height() - bitmap.height * scale) / 2f,
)
slide.shader.setLocalMatrix(cropMatrix)
picturePaint.shader = slide.shader
picturePaint.alpha = alpha
canvas.drawPath(shape, picturePaint)
picturePaint.shader = null
}
private fun drawSample(canvas: Canvas) {
platePaint.shader = sampleShader
canvas.drawPath(shape, platePaint)
platePaint.shader = null
sceneryPaint.alpha = SUN_ALPHA
canvas.drawPath(sun, sceneryPaint)
sceneryPaint.alpha = FAR_HILL_ALPHA
canvas.drawPath(farHill, sceneryPaint)
sceneryPaint.alpha = NEAR_HILL_ALPHA
canvas.drawPath(nearHill, sceneryPaint)
val layout = hint ?: return
canvas.save()
canvas.translate(
frame.left + (frame.width() - layout.width) / 2f,
frame.top + frame.height() * HINT_CENTRE_SHARE - layout.height / 2f,
)
layout.draw(canvas)
canvas.restore()
}
private fun currentIconShape(): IconShape = PreferenceManager2.getInstance(context).iconShape.firstCached()
private fun activityContext(): ActivityContext? {
var current: Context? = context
while (current != null) {
if (current is ActivityContext) return current
current = (current as? ContextWrapper)?.baseContext
}
return null
}
companion object {
private const val TAG = "PictureWidgetView"
/** How long each picture of several is shown. */
const val INTERVAL_MS = 10_000L
/** How long one picture takes to fade into the next. */
const val FADE_MS = 600L
/** How long the size has to stay put during a resize before the picture is read anew. */
const val RESIZE_SETTLE_MS = 200L
/** Corner radius, as a share of the shorter side, where there are no icons to match. */
const val LOOSE_CORNER_SHARE = 0.18f
// An icon's shadow as Launcher3 draws it (ShadowGenerator.BLUR_FACTOR and
// KEY_SHADOW_DISTANCE), as shares of the icon size.
const val SHADOW_BLUR_SHARE = 1.68f / 48f
const val SHADOW_DROP_SHARE = 1f / 48f
const val SHADOW_COLOR = 0x26000000
/** Under a picture, where it shows only while the next one fades in. */
const val PLATE_COLOR = 0xFF17181C.toInt()
const val SAMPLE_COLOR_START = 0xFF5B6B95.toInt()
const val SAMPLE_COLOR_END = 0xFF2B3350.toInt()
const val SCENERY_COLOR = 0xFFFFFFFF.toInt()
const val SUN_ALPHA = 150
const val FAR_HILL_ALPHA = 40
const val NEAR_HILL_ALPHA = 64
const val HINT_COLOR = 0xF2FFFFFF.toInt()
const val HINT_MAX_LINES = 2
const val HINT_MAX_HEIGHT_SHARE = 0.4f
const val HINT_CENTRE_SHARE = 0.36f
/**
* Puts a [PictureWidgetView] into a widget host view when the widget is the picture
* widget, and returns it; returns null for every other widget. Launcher3's host view
* calls this in place of asking Android for the widget's views.
*/
@JvmStatic
fun attachIfOwn(
host: LauncherAppWidgetHostView,
appWidgetId: Int,
info: AppWidgetProviderInfo?,
): View? {
if (info == null || info.provider != PictureWidgetProvider.componentName) return null
// A widget that still waits to be set up draws its own "tap to set up".
if (host is PendingAppWidgetHostView) return null
var view: PictureWidgetView? = null
for (i in host.childCount - 1 downTo 0) {
val child = host.getChildAt(i)
if (child is PictureWidgetView && view == null) view = child else host.removeViewAt(i)
}
if (view == null) {
view = PictureWidgetView(host.context)
host.addView(
view,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
}
view.bind(appWidgetId)
return view
}
}
}
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.2.0
versionName=0.9.0
+2
View File
@@ -128,6 +128,8 @@ coil-compose = "io.coil-kt:coil-compose:2.7.0"
google-material = "com.google.android.material:material:1.14.0"
hoko-blur = "io.github.hokofly:hoko-blur:1.5.5"
javax-inject = "javax.inject:javax.inject:1"
# Flexera Launcher: QR code on the About screen (app.flexera.ui.FlexeraAbout).
zxing-core = "com.google.zxing:core:3.5.3"
kdrag0n-colorkt = "dev.kdrag0n:colorkt:1.0.5"
leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.14"
leakcanary-androidstudio = "com.android.tools.studio.leakcanary:leakcanary:1.0.0"
@@ -214,6 +214,10 @@ class LawnchairApp : LauncherApplication() {
val launcher: LawnchairLauncher?
get() = activities.filterIsInstance<LawnchairLauncher>().firstOrNull()
// Flexera Launcher: see LawnchairApp.launchers.
val launchers: List<LawnchairLauncher>
get() = activities.filterIsInstance<LawnchairLauncher>()
fun finishAll() {
HashSet(activities).forEach { it.finish() }
}
@@ -292,6 +296,14 @@ class LawnchairApp : LauncherApplication() {
@JvmStatic
val launcher: LawnchairLauncher? get() = instance.activityHandler.launcher
/**
* Flexera Launcher: every copy of the launcher that is open. There are two when it was
* opened like an app (from the installer's "Open", say) and then made the home app: one
* in the app's own task and one as the home screen. [launcher] picks either of them.
*/
@JvmStatic
val launchers: List<LawnchairLauncher> get() = instance.activityHandler.launchers
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
fun Launcher.showQuickstepWarningIfNecessary() {
val launcher = this
@@ -517,7 +517,10 @@ class LawnchairLauncher : QuickstepLauncher() {
fun recreateIfNotScheduled() {
if (sRestartFlags == 0) {
recreate()
// Flexera Launcher: every open copy, not only this one. A changed setting used to
// rebuild whichever copy LawnchairApp.launcher happened to return, which could be
// the one in the background, and the home screen kept its old look until restart.
(LawnchairApp.launchers + this).distinct().forEach { it.recreate() }
}
}
@@ -218,7 +218,14 @@ class PreferenceManager @Inject constructor(
val flexeraAlphabetOnLeft = BoolPref("pref_flexeraAlphabetOnLeft", false, recreate)
// Flexera Launcher: whether a slide along the home screen's edge opens the drawer at a letter (PRD AD-10).
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false)
// On unless switched off, by the owner's decision of 12 November 2026.
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", true)
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
// Flexera Launcher: how large the rows of the drawer list are, "compact" or "comfy" (FlexeraDrawer.RowSize).
val flexeraDrawerRowSize = StringPref("pref_flexeraDrawerRowSize", "compact", recreate)
override fun close() {
TODO("Not yet implemented")
@@ -874,6 +874,18 @@ class PreferenceManager2 @Inject constructor(
defaultValue = GestureHandlerConfig.NoOp,
)
// Flexera Launcher: quick launch. What a swipe on from the first home screen page, towards
// the page that is not there, does; and the same past the last page (app.flexera.home.QuickLaunch).
val firstPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
key = stringPreferencesKey("flexera_first_page_swipe_gesture_handler"),
defaultValue = GestureHandlerConfig.NoOp,
)
val lastPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
key = stringPreferencesKey("flexera_last_page_swipe_gesture_handler"),
defaultValue = GestureHandlerConfig.NoOp,
)
val autoUpdaterNightly = preference(
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
@@ -40,6 +40,12 @@ fun BackupAndRestorePreference(
subtitle = stringResource(R.string.restore_nova_backup_description),
onClick = restoreNovaBackupOpener(),
)
// Flexera Launcher: home screens from a Smart Launcher backup (app.flexera.backup).
ClickablePreference(
label = stringResource(R.string.flexera_import_smart_launcher),
subtitle = stringResource(R.string.flexera_import_smart_launcher_description),
onClick = app.flexera.backup.smartLauncherImportOpener(),
)
}
}
}
@@ -1,5 +1,6 @@
package app.lawnchair.ui.preferences.destinations
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -53,6 +54,23 @@ fun GesturePreferences(
label = stringResource(id = R.string.gesture_back_tap),
)
}
// Flexera Launcher: quick launch, a swipe on past the first or the last home screen page
// (app.flexera.home.QuickLaunch). It rides on Android's own page stretch, new in Android 12.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PreferenceGroup(
heading = stringResource(id = R.string.flexera_quick_launch),
description = stringResource(id = R.string.flexera_quick_launch_description),
) {
GestureHandlerPreference(
adapter = prefs.firstPageSwipeGestureHandler.getAdapter(),
label = stringResource(id = R.string.flexera_quick_launch_first_page),
)
GestureHandlerPreference(
adapter = prefs.lastPageSwipeGestureHandler.getAdapter(),
label = stringResource(id = R.string.flexera_quick_launch_last_page),
)
}
}
PreferenceGroup(heading = stringResource(id = R.string.sleep_mode_label)) {
ListPreference(
adapter = prefs.sleepMode.getAdapter(),
@@ -14,6 +14,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import app.lawnchair.backup.ui.CreateBackupScreen
import app.flexera.backup.smartLauncherImportGraph
import app.lawnchair.backup.ui.restoreBackupGraph
import app.lawnchair.backup.ui.restoreNovaBackupGraph
import app.lawnchair.preferences.BasePreferenceManager
@@ -252,5 +253,6 @@ fun PreferenceNavigation(
restoreBackupGraph()
restoreNovaBackupGraph()
smartLauncherImportGraph() // Flexera Launcher: Smart Launcher import
}
}
@@ -224,3 +224,8 @@ data class RestoreBackup(val base64Uri: String) : PreferenceRoute
@Serializable
data class RestoreNovaBackup(val base64Uri: String) : PreferenceRoute
// Flexera Launcher: import of a Smart Launcher backup (app.flexera.backup). The route has to
// be declared here because PreferenceRoute is sealed.
@Serializable
data class ImportSmartLauncherBackup(val base64Uri: String) : PreferenceRoute
@@ -3,6 +3,7 @@ package app.lawnchair.views
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import app.flexera.drawer.FlexeraDrawer
import app.lawnchair.preferences.PreferenceManager
import com.android.launcher3.R
import com.android.launcher3.util.SystemUiController
@@ -24,7 +25,10 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
val threshold = STATUS_BAR_COLOR_FORCE_UPDATE_THRESHOLD
val forceChange = visibility == VISIBLE &&
alpha > threshold &&
Color.alpha(mBackgroundColor) / (255f * drawerOpacity) > threshold
// Flexera Launcher: the scrim is at full strength when it reaches the alpha of the
// drawer's backdrop. Lawnchair measured against its drawer opacity setting, which
// is 0 here.
Color.alpha(mBackgroundColor) / Color.alpha(FlexeraDrawer.BACKDROP_COLOR).toFloat() > threshold
with(systemUiController) {
if (forceChange) {
updateUiState(SystemUiController.UI_STATE_SCRIM_VIEW, !isScrimDark)
@@ -34,9 +38,8 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
}
}
override fun isScrimDark() = if (drawerOpacity <= 0.3f) {
!Themes.getAttrBoolean(context, R.attr.isWorkspaceDarkText)
} else {
super.isScrimDark()
}
// Flexera Launcher: judged by the scrim's own colour, as Launcher3 does. Lawnchair went by
// the wallpaper here because its drawer scrim could be see-through; this drawer's backdrop
// is always dark, and the status bar icons over it have to be light.
override fun isScrimDark() = super.isScrimDark()
}
@@ -219,6 +219,9 @@ public class AllAppsState extends LauncherState {
} else {
backgroundColor = ColorTokens.AllAppsScrimColor.resolveColor(launcher);
}
// Flexera Launcher: the drawer's backdrop is dark whatever the theme and the wallpaper,
// so that the white names on it stay readable.
backgroundColor = app.flexera.drawer.FlexeraDrawer.BACKDROP_COLOR;
return new ScrimColors(backgroundColor, /* foregroundColor */ Color.TRANSPARENT);
}
}
@@ -17,6 +17,7 @@
package com.android.launcher3.uioverrides.touchcontrollers;
import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE;
import static com.android.app.animation.Interpolators.DECELERATE;
import static com.android.launcher3.LauncherAnimUtils.SCRIM_COLORS;
import static com.android.launcher3.LauncherAnimUtils.newSingleUseCancelListener;
import static com.android.launcher3.LauncherState.ALL_APPS;
@@ -35,6 +36,7 @@ import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.graphics.PointF;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
@@ -81,10 +83,23 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
private boolean mDidTouchStartInNavBar;
// Flexera Launcher: swipe up and hold hands the same touch to the wave alphabet (PRD AD-17).
// "Started" is set when the pause is detected and the drawer begins to finish opening;
// "ready" is set once it is fully open and the alphabet can take the finger's position.
// "Started" is set when the pause is detected: the alphabet takes the finger at once, while
// the drawer finishes opening under it. "Drawer open" is set when that has finished.
private boolean mFlexeraScrubStarted;
private boolean mFlexeraScrubReady;
private boolean mFlexeraDrawerOpen;
// Where the finger was at the last drag event, in the drag layer's coordinates.
private float mFlexeraLastX;
private float mFlexeraLastY;
// How long the drawer takes to finish opening once a hold is detected. Short, because the
// alphabet is already following the finger and the list has to be readable behind it.
private static final long FLEXERA_HOLD_OPEN_MS = 120;
// A finger that has not moved for this long is holding. Swipe to recents waits twice as
// long; here a false alarm only opens the drawer the swipe was opening anyway.
private static final long FLEXERA_HOLD_STILL_MS = 150;
// A finger that lifts sooner than this after the hold was noticed was not holding; see
// onDragEnd. Too short a time to have read the letter and meant it.
private static final long FLEXERA_MIN_HOLD_MS = app.flexera.drawer.FlexeraDrawer.MIN_SCRUB_MS;
private long mFlexeraScrubStartTime;
private boolean mStartedOverview;
private boolean mReachedOverview;
// The last recorded displacement before we reached overview.
@@ -177,9 +192,10 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
// Flexera Launcher: a pause during an ordinary swipe up to the drawer starts a scrub.
mFlexeraScrubStarted = false;
mFlexeraScrubReady = false;
mFlexeraDrawerOpen = false;
if (flexeraHoldToScrub()) {
mMotionPauseDetector.setOnMotionPauseListener(this::onFlexeraHoldDetected);
mMotionPauseDetector.setForcePauseTimeout(FLEXERA_HOLD_STILL_MS);
}
if (mFromState == NORMAL && mToState == HINT_STATE) {
@@ -219,8 +235,15 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
if (mFlexeraScrubStarted) {
// Flexera Launcher: lifting the finger ends the scrub and leaves the drawer open at
// the chosen letter. If the drawer is still opening, its end callback finishes up.
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
if (mFlexeraScrubReady) {
if (SystemClock.uptimeMillis() - mFlexeraScrubStartTime < FLEXERA_MIN_HOLD_MS) {
// The finger left almost as soon as the hold was noticed, so it was not
// holding: an ordinary swipe slowed down at its end. Open the drawer the way
// that swipe would have, with the list where it was.
app.flexera.drawer.FlexeraDrawer.cancelScrubFromSwipe(mLauncher.getAppsView());
} else {
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
}
if (mFlexeraDrawerOpen) {
onSwipeInteractionCompleted(ALL_APPS);
}
} else if (mStartedOverview) {
@@ -288,26 +311,34 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
}
/**
* Flexera Launcher: the finger paused part-way through a swipe up. Finish opening the drawer
* and, once it is open, let the same touch scrub the wave alphabet. This mirrors
* {@link #onMotionPauseDetected()}, which does the same hand-over to Overview.
* Flexera Launcher: the finger paused part-way through a swipe up. From this moment the same
* touch scrubs the wave alphabet, with no wait: the alphabet takes the finger where it is,
* and the drawer finishes opening under it.
*/
private void onFlexeraHoldDetected() {
if (mCurrentAnimation == null || mFlexeraScrubStarted || !flexeraHoldToScrub()) {
return;
}
mCurrentAnimation.getTarget().addListener(newSingleUseCancelListener(() ->
mLauncher.getStateManager().goToState(ALL_APPS, true, forSuccessCallback(() -> {
mFlexeraScrubReady = true;
if (!mDetector.isDraggingState()) {
// The finger lifted while the drawer was still opening.
onSwipeInteractionCompleted(ALL_APPS);
}
}))));
mCurrentAnimation.getTarget().removeListener(mClearStateOnCancelListener);
mCurrentAnimation.dispatchOnCancel();
mFlexeraScrubStarted = true;
mFlexeraScrubStartTime = SystemClock.uptimeMillis();
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
// Play the swipe's own animation to its end, as lifting the finger would, only faster.
// Going through the state manager instead would take its full 600 ms opening time.
mCurrentAnimation.setEndAction(() -> {
mFlexeraDrawerOpen = true;
if (!mDetector.isDraggingState()) {
// The finger lifted while the drawer was still opening.
onSwipeInteractionCompleted(ALL_APPS);
}
});
ValueAnimator anim = mCurrentAnimation.getAnimationPlayer();
anim.setFloatValues(
Utilities.boundToRange(mCurrentAnimation.getProgressFraction(), 0f, 1f), 1f);
anim.setDuration(FLEXERA_HOLD_OPEN_MS).setInterpolator(DECELERATE);
mCurrentAnimation.dispatchOnStart();
anim.start();
VibratorWrapper.INSTANCE.get(mLauncher).vibrate(OVERVIEW_HAPTIC);
}
@@ -326,13 +357,15 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
@Override
public boolean onDrag(float yDisplacement, float xDisplacement, MotionEvent event) {
// Flexera Launcher: remembered so that a hold, which can be noticed between two touch
// events, knows where the finger is.
mFlexeraLastX = event.getX();
mFlexeraLastY = event.getY();
if (mFlexeraScrubStarted) {
// Flexera Launcher: the swipe has become a scrub; the finger now moves the alphabet,
// not the drawer. Until the drawer has finished opening there is nothing to move yet.
if (mFlexeraScrubReady) {
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), event);
}
// not the drawer.
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
return true;
}
if (mStartedOverview) {
@@ -76,6 +76,9 @@ public class MotionPauseDetector {
private boolean mDisallowPause;
// Time at which speed became < mSpeedSlow (only used if mMakePauseHarderToTrigger == true).
private long mSlowStartTime;
// Flexera Launcher: how long without motion counts as a pause. A gesture may shorten it
// (see setForcePauseTimeout); clear() puts the stock value back.
private long mForcePauseTimeoutMs = FORCE_PAUSE_TIMEOUT;
public MotionPauseDetector(Context context) {
this(context, false);
@@ -122,6 +125,15 @@ public class MotionPauseDetector {
mIsTrackpadGesture = isTrackpadGesture;
}
/**
* Flexera Launcher: sets how long the finger must stay still before that alone counts as a
* pause, for the gesture in progress. Swipe up and hold (PRD AD-17) uses a shorter time
* than the stock 300 ms, so the alphabet answers a hold without a noticeable wait.
*/
public void setForcePauseTimeout(long timeoutMs) {
mForcePauseTimeoutMs = timeoutMs;
}
/**
* @param disallowPause If true, we will not detect any pauses until this is set to false again.
*/
@@ -153,7 +165,7 @@ public class MotionPauseDetector {
? TEST_HARNESS_TRIGGER_TIMEOUT
: mMakePauseHarderToTrigger
? HARDER_TRIGGER_TIMEOUT
: FORCE_PAUSE_TIMEOUT;
: mForcePauseTimeoutMs;
mForcePauseTimeout.setAlarm(timeoutMs);
float newVelocity = mVelocityProvider.addMotionEvent(ev, ev.getPointerId(pointerIndex));
if (mPreviousVelocity != null) {
@@ -262,6 +274,7 @@ public class MotionPauseDetector {
mIsTrackpadGesture = false;
mIsPaused = mHasEverBeenPaused = false;
mSlowStartTime = 0;
mForcePauseTimeoutMs = FORCE_PAUSE_TIMEOUT;
mForcePauseTimeout.cancelAlarm();
}
+16 -1
View File
@@ -195,7 +195,8 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
private boolean mLayoutHorizontal;
private final boolean mIsRtl;
private final int mIconSize;
// Flexera Launcher: not final, see setIconSizeOverride.
private int mIconSize;
@ViewDebug.ExportedProperty(category = "launcher")
private boolean mHideBadge = false;
@@ -1413,6 +1414,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);
}
}
}
@@ -1558,6 +1564,15 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
return mIconSize;
}
/**
* Flexera Launcher: the rows of the drawer list come in more than one size, chosen by
* the owner at run time, which the layout attribute cannot follow. Call before an icon
* is applied.
*/
public void setIconSizeOverride(int iconSize) {
mIconSize = iconSize;
}
public boolean isDisplaySearchResult() {
return mDisplay == DISPLAY_SEARCH_RESULT
|| mDisplay == DISPLAY_SEARCH_RESULT_SMALL
@@ -150,7 +150,10 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
DeviceProfile profile = mActivity.getDeviceProfile();
final PointF appWidgetScale = profile.getAppWidgetScale((ItemInfo) child.getTag());
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
appWidgetScale.x, appWidgetScale.y, mBorderSpace, profile.widgetPadding);
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
// Flexera Launcher: the picture widget takes its cells whole.
app.flexera.widget.PictureWidgetProvider.cellInset(child,
profile.widgetPadding));
} else {
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
mBorderSpace);
@@ -175,7 +178,9 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
if (child instanceof NavigableAppWidgetHostView) {
final PointF appWidgetScale = dp.getAppWidgetScale((ItemInfo) child.getTag());
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
appWidgetScale.x, appWidgetScale.y, mBorderSpace, dp.widgetPadding);
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
// Flexera Launcher: the picture widget takes its cells whole.
app.flexera.widget.PictureWidgetProvider.cellInset(child, dp.widgetPadding));
} else if (isChildQsb(child)) {
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
mBorderSpace);
+11 -1
View File
@@ -372,6 +372,13 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
setHapticFeedbackEnabled(false);
initWorkspace();
// Flexera Launcher: quick launch. The stretch at either end of the pages doubles as a
// gesture that opens an app the owner has chosen (app.flexera.home.QuickLaunch). From
// Android 12, where that stretch is Android's own; before, Lawnchair draws one itself.
if (Utilities.ATLEAST_S) {
mEdgeGlowLeft = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, true, mIsRtl);
mEdgeGlowRight = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, false, mIsRtl);
}
// Disable multitouch across the workspace/all apps/customize tray
setMotionEventSplittingEnabled(true);
@@ -1476,7 +1483,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
public void setLauncherOverlay(LauncherOverlayTouchProxy overlay) {
final EdgeEffectCompat newEffect;
if (overlay == null) {
newEffect = new EdgeEffectCompat(getContext());
// Flexera Launcher: without a feed, the first page's end is quick launch again.
newEffect = Utilities.ATLEAST_S
? app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, !mIsRtl, mIsRtl)
: new EdgeEffectCompat(getContext());
mOverlayEdgeEffect = null;
} else {
newEffect = mOverlayEdgeEffect = new OverlayEdgeEffect(getContext(), overlay);
@@ -192,6 +192,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
protected View mSearchContainer;
protected SearchUiManager mSearchUiManager;
protected boolean mUsingTabs;
// Flexera Launcher: see replaceAppsRVContainer.
private boolean mFlexeraSearchFieldScrolls;
protected RecyclerViewFastScroller mTouchHandler;
/** {@code true} when rendered view is in search state instead of the scroll state. */
@@ -849,9 +851,28 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
layoutBelowSearchContainer(getSearchRecyclerView(), /* tabs= */ false);
}
// Flexera Launcher: the search field scrolls away with the list. For that the list
// starts at the top of the drawer, not under the field, and keeps the field's room as
// top padding (AdapterHolder.applyPadding); FlexeraDrawer.moveSearchFieldWithList then
// moves the field as the list scrolls. Work tabs sit between the two and stay put, so
// with tabs the field stays where it is, as before.
mFlexeraSearchFieldScrolls =
!showTabs && !isAppDrawerSearchBarHidden() && !isSearchBarFloating();
if (mFlexeraSearchFieldScrolls
&& rvContainer.getLayoutParams() instanceof RelativeLayout.LayoutParams lp) {
lp.removeRule(RelativeLayout.ALIGN_TOP);
lp.addRule(RelativeLayout.ALIGN_PARENT_TOP);
lp.topMargin = 0;
}
updateSearchResultsVisibility();
}
/** Flexera Launcher: true while the search field scrolls away with the list. */
public boolean flexeraSearchFieldScrolls() {
return mFlexeraSearchFieldScrolls;
}
/**
* Sets up header rows and hides the header shell when neither tabs nor visible row content
* requires it, keeping adapter top padding consistent with that visibility.
@@ -917,6 +938,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
}
protected void updateHeaderScroll(int scrolledOffset) {
// Flexera Launcher: the search field moves up with the list.
app.flexera.drawer.FlexeraDrawer.moveSearchFieldWithList(this);
if (isAppDrawerSearchBarHidden() && !mUsingTabs)
return;
@@ -1421,6 +1444,11 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
setPadding(grid.allAppsLeftRightMargin, topPadding, grid.allAppsLeftRightMargin, 0);
}
InsettableFrameLayout.dispatchInsets(this, insets);
// Flexera Launcher: the list's top padding follows the search field's place, which the
// line above may just have changed.
if (mFlexeraSearchFieldScrolls) {
mAH.get(AdapterHolder.MAIN).applyPadding();
}
}
/**
@@ -1905,7 +1933,17 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
if (isSearchBarFloating()) {
bottomOffset += mSearchContainer.getHeight();
}
mRecyclerView.setPadding(mPadding.left, mPadding.top, mPadding.right,
int top = mPadding.top;
// Flexera Launcher: the room the search field takes at the top of the list, now
// that the list starts above the field (see replaceAppsRVContainer): the
// field's own distance from the top, and what used to be kept under its top edge.
if (mFlexeraSearchFieldScrolls && isMain()) {
top += getResources().getDimensionPixelSize(R.dimen.all_apps_header_top_margin);
if (mSearchContainer.getLayoutParams() instanceof MarginLayoutParams field) {
top += field.topMargin;
}
}
mRecyclerView.setPadding(mPadding.left, top, mPadding.right,
mPadding.bottom + bottomOffset);
}
}
@@ -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
@@ -87,6 +87,11 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
private boolean mTrackingWidgetUpdate = false;
// Flexera Launcher: the view that draws this widget when it is one of the launcher's own
// (the picture widget); null for every ordinary widget.
@Nullable
private View mFlexeraOwnView;
private int mFocusRectOutsets = 0;
public LauncherAppWidgetHostView(Context context) {
@@ -131,6 +136,20 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
}
}
// Flexera Launcher: Android puts a widget's stand-in layout (its initialLayout) into this
// view whenever a widget without views has its colours reapplied, which Launcher3 does to
// match the wallpaper. The launcher's own widget is drawn by mFlexeraOwnView and gets
// nothing in that place.
@Override
protected View getDefaultView() {
if (mFlexeraOwnView != null) {
View nothing = new View(getContext());
nothing.setVisibility(GONE);
return nothing;
}
return super.getDefaultView();
}
@Override
public boolean onLongClick(View view) {
if (mIsScrollable) {
@@ -143,6 +162,12 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
@Override
public void setAppWidget(int appWidgetId, AppWidgetProviderInfo info) {
super.setAppWidget(appWidgetId, info);
// Flexera Launcher: the launcher's own picture widget is drawn by a view of the
// launcher, not by views Android sends over; nothing more is loaded for it.
mFlexeraOwnView = app.flexera.widget.PictureWidgetView.attachIfOwn(this, appWidgetId, info);
if (mFlexeraOwnView != null) {
return;
}
if (!mTrackingWidgetUpdate && appWidgetId != -1) {
mTrackingWidgetUpdate = true;
Log.i(TAG, "App widget created with id: " + appWidgetId);
@@ -154,6 +179,10 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
@Override
public void updateAppWidget(RemoteViews remoteViews) {
// Flexera Launcher: see setAppWidget.
if (mFlexeraOwnView != null) {
return;
}
if (mTrackingWidgetUpdate && remoteViews != null) {
Log.i(TAG, "App widget with id: " + getAppWidgetId() + " loaded");
if (ATLEAST_Q) {
@@ -21,6 +21,7 @@ import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_SIZES
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.util.SizeF
import com.android.launcher3.dagger.ApplicationContext
import com.android.launcher3.util.Executors
@@ -46,13 +47,24 @@ open class WidgetSizeHandler @Inject constructor(@ApplicationContext private val
spanY: Int,
) {
Executors.UI_HELPER_EXECUTOR.execute {
val widgetManager = AppWidgetManager.getInstance(context)
val sizeOptions = WidgetSizes.getWidgetSizeOptions(context, info.provider, spanX, spanY)
if (
sizeOptions.getWidgetSizeList() !=
widgetManager.getAppWidgetOptions(widgetId).getWidgetSizeList()
)
widgetManager.updateAppWidgetOptions(widgetId, sizeOptions)
// Flexera Launcher: a widget that still waits for "tap to set up" has an id that
// Android has reserved but not yet tied to a provider. Android answers a size
// update for such an id with a NullPointerException of its own (seen on Android 17:
// AppWidgetServiceImpl.updateAppWidgetOptions), which arrives here and used to end
// the launcher whenever the grid reloaded with such a widget on a home screen. That
// is the normal state after a layout import, so the answer is logged and dropped;
// the size is sent again when the widget is set up.
try {
val widgetManager = AppWidgetManager.getInstance(context)
val sizeOptions = WidgetSizes.getWidgetSizeOptions(context, info.provider, spanX, spanY)
if (
sizeOptions.getWidgetSizeList() !=
widgetManager.getAppWidgetOptions(widgetId).getWidgetSizeList()
)
widgetManager.updateAppWidgetOptions(widgetId, sizeOptions)
} catch (e: RuntimeException) {
Log.w("WidgetSizeHandler", "Android refused the size of widget $widgetId", e)
}
}
}