33 Commits
Author SHA1 Message Date
TMC 9026a8ec7a Release 0.15.0 2026-11-13 12:26:22 +01:00
TMCandClaude Opus 5.5 f5d7cf07ed Line up every widget with the icons, as the Pictures widget does
Widgets of other apps used to fill their cells minus Launcher3's widget
padding, which is a few pixels wider than the icons' edges and reaches down
into the room kept for icon labels; on the owner's phone they hung about
13 px past the icons sideways and 40 px below. Now the room a widget keeps in
its cells is the distance from the cell edges to the icon edges, worked out
as IconFrame does for the launcher's own widgets, so every widget's edges
meet the edges of the icons around it.

The one function (FlexeraWidgets.appWidgetPadding) is used for the layout,
for the size the app is told, for the drop animation and through
WidgetSizes for previews. The cells a widget needs are still worked out
with Launcher3's padding, so no widget changes its cell count. A new switch
under Settings, Home screen, Widgets ("Line up with icons", on by default)
turns it off; Lawnchair's Widget padding slider shows only then.

Checked on the emulator against lines drawn at the icon edges: Google
Calendar's widgets meet them within 2 px; Google Clock's sits about 20 px
inside, because it draws an empty margin of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:19:33 +01:00
TMCandClaude Opus 5.5 0dbb146c75 Quick launch: start the app as the finger lifts, with no opening animation
The app used to be started only once the black page had settled, and then
came in with Android's zoom. Now it is started the moment the swipe is
decided, while the page is still settling, with FLAG_ACTIVITY_NO_ANIMATION
and the same intent as its icon, so it appears on the page at full size as
soon as it has a frame. An app still in memory shows its last screen at
once. Apps of another profile still go through LauncherApps, which takes no
flags, and open with the usual animation.

On the emulator, lift to first app frame went from 646 ms to 140 ms
(Camera in memory), 684 ms to 248 ms (Camera closed), and 249 ms for Clock
in memory, which came up on its last screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:04:50 +01:00
TMCandClaude Opus 5.5 900071ccac Quick launch: an app opens on a page of its own, with the home screen blurring away
When a swipe past the first or the last home screen page is set to open an
app, the home screen no longer stretches. It moves aside with the finger, as
for a real page, an empty black page comes in from that edge, and the home
screen (icons and wallpaper) blurs and darkens the further it goes. Let go
past 40% of the width, or flick, and the page settles over the whole screen
and the app comes up on it; let go earlier and the home screen comes back.
Swipes set to other actions keep the stretch.

The home screen is moved with the drag layer's animation matrix, which does
not move touch coordinates, so the pages keep reading the same touch. The
blur is a second index on the folder blur's property in the depth controller.
The status bar icons turn light over the black page. Once the app is in
front the launcher stops and the page resets; if nothing comes within 1.5 s
the home screen slides back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 11:02:10 +01:00
TMCandClaude Opus 5.5 0fbefd2ca6 Website: chapter 8 labels on the layers' corners, one layer at a time; phone centred
Labels: each layer's pin now sits on its outer rounded corner, the
layer's rightmost point, so a tag straight across from it is joined by
a short line that crosses nothing; the stack moves left by just enough
for the tags to fit, and the open stack is a little smaller so it keeps
clear of the chapter's heading. While the stack is open, scrolling walks
through the layers from the glass to the wallpaper: the one looked at
stays bright and its tag lights up cyan, the others dim (where the tags
are shown). Chapter 8 is three screens long for it.

Centring: the labels' list sat in the stage's grid flow, so the grid
split its height into two rows and the story phone was centred in the
top one (about 100 px high on a 1146 px window). It is out of the flow
now; measured equal gaps above and below at 2000 x 1146, 1440 x 900
and 1280 x 720.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:20:10 +01:00
TMCandClaude Opus 5.5 058fecd27f Website: the story phone comes apart as its eighth chapter
The separate Under the glass section is gone; its exploded phone is now
chapter 8 of How it works, so the pinned story phone carries straight
into it. After Out of sight the list closes, the drawn phone is swapped
for its layered double (the same home screen closed; measured at about
1-2% of pixels apart, from anti-aliasing), which tilts and comes apart,
holds, and goes back together before the section ends. The chapter is
two screens long and its text stays put meanwhile.

Labels redone: a column of numbered solid tags beside the stack, evenly
spaced, each joined by an elbow line to a dot on its layer's edge; the
stack moves left as it opens to make room. The glass sheen and the frame
now match the drawn phone, so the swap is not seen.

Also fixed: the layered phone sat at its full 414 px until first used,
which made the page wider than a phone and zoomed it out; it is scaled
from the start, and on phones the story stage clips. The story now
counts its progress in chapter heights rather than window heights,
which drifted apart on phones when the address bar hides. The long
chapter's height lost to the general chapter rule; it now wins.

Section numbers close up (Details 02 to Get it 06); the page alphabet's
U leads to the new chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:11:53 +01:00
TMCandClaude Opus 5.5 6c68cb1369 Website: remove the living wallpaper behind the hero
The flowing bands of light on a canvas behind the hero are gone (markup,
styles, code and the README line); the hero keeps its dark background
with the grid and the two soft glows. The link-preview image is retaken
without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:59:18 +01:00
TMCandClaude Opus 5.5 f1fa44b086 Website: wave headline, page alphabet, exploded phone, race and more
- Wave headline: the hero's letters swell from weight 560 to 900 around
  the mouse (a wave rolls through on its own without one).
- Page alphabet: one letter per section on the right edge, like the
  launcher's; hover shows the wave and a bubble, drag jumps between
  sections, tap scrolls there; on phones it appears while scrolling.
  Blended so it reads on dark, cyan and violet sections.
- Detail cards are switches now: left-handed alphabet, compact/comfy
  rows and four icon shapes, applied to every phone on the page.
- Under the glass: a new section where the phone pulls apart into its
  layers (wallpaper, widgets, icons, app list, glass) with labels.
- Race: a new section; find an app in a shuffled grid of 40, then in the
  list with the wave alphabet, timed. The list phone reuses the
  playground controller, locked to the list. The phone comes on screen
  before the clock starts.
- Living wallpaper: light bands on a canvas behind the hero.
- Magnetic buttons and a finger cursor over the phones (desktop only).
- Easter eggs: scrub to F in the playground, or type "flexera".
- A vibration tick per letter while scrubbing, on phones that have it.
- 40 generic apps instead of 29; the alphabet now sits from the bottom.
- Fixed: the alphabet's magnifier had no size, so its 300 px svg box
  covered the list once the alphabet moved to the left and took taps.

Reduced motion stops everything that moves on its own. Checked in
Chrome at 1440 x 900 and 390 x 844: no console errors or sideways
scrolling, every playground gesture, the camera chapter without flash,
each switch, both eggs, a full race, and the QR code as drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:57:01 +01:00
TMC c93bb1df9c Release 0.14.0 2026-11-12 21:46:11 +01:00
TMCandClaude Opus 5.5 bfb70cab63 Make the update buttons stand out; install right after the download
The Updates section of About is now a card with the status on top and
the next step as a full-width filled button: Check for updates, Download
and install, Install now, or Try again. Before, each step was a plain
settings row that looked like every other row.

Download and install hands the APK to Android's installer as soon as the
download is done, without another tap. If Android first wants this app
to be allowed to install apps, its setting opens, and the installer
follows by itself when the owner comes back having allowed it. The
request is kept on disk for 30 minutes, so it survives Android ending
the process in between but does not pop an installer up hours later.

Tested on the emulator with a release build numbered 0.12.9: check,
download with progress, the Install unknown apps setting, the installer
opening by itself on return, and the update to 0.13.0 from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:45:01 +01:00
TMCandClaude Opus 5.5 4746d194f4 Website: no white lines between sections
The sections now meet with a plain change of colour: the white rule at
the bottom of the hero, story, details, privacy, playground and download
sections is gone, as is the white edge of the marquee bands (the tilted
pink band keeps a dark edge where it crosses the cyan one) and the white
line under the menu bar once the page scrolls. Lines inside sections
(cards, stats, FAQ) stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:31:13 +01:00
TMCandClaude Opus 5.5 12ec415e48 Website: fill the big background words instead of outlining them
Unbounded is a variable font whose letters are built from overlapping
shapes; an outline (text-stroke) traces every overlap, which drew stray
lines inside the hero's giant FLEXERA and the story's chapter number.
Both are now a faint solid fill (the hero's with a cyan-to-violet tint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:28:07 +01:00
TMCandClaude Opus 5.5 89fa6ef6ca Website: quick launch slides the camera in; no white flash
The camera now behaves as the page to the left of the first one: a swipe
right pushes the home screen (wallpaper included) to the right while the
camera follows the finger in from the left edge, and finishes the slide
when the finger lets go. Before, the home screen rubber-banded and the
camera was revealed by a clip from the edge. Same in the scroll story,
the playground gesture and its demo button; in the playground a short
swipe springs back, and a swipe left slides the camera out again.

The white flash at the end of the camera chapter was a shutter flash
over the whole screen; it read as a glitch while scrolling into the next
chapter. The shutter button now presses in instead.

Checked in Chrome: the camera chapters stepped through in 0.02 steps
show no brightness spike; the playground swipe in, swipe back, short
swipe and demo button; no console errors or sideways scrolling at
1440 x 900 and 390 x 844.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:25:36 +01:00
TMCandClaude Opus 5.5 d273543070 Add the landing page in WEBSITE
One page, English, plain HTML/CSS/JS with no build step and no third-party
requests: the four fonts (OFL) are served from the folder.

Brutal layout in the icon's colours (cyan, electric blue, violet, pink on
near black): hard 2px lines, solid slabs, offset shadows, marquee bands.
The centrepiece is a phone drawn in HTML at 390 x 844, used three times:
a looping teaser in the hero; a scroll-driven story of seven chapters
(home, swipe up, wave-alphabet scrub, folder blur, quick launch, clock
options, hide app) where the scroll position maps to a state, so it plays
both ways; and a playground that answers to touch and mouse (swipe up,
scrub, hold to hide with Undo, folder, quick launch, clock options, page
two, edge swipe home) plus buttons that run each demo.

Also: privacy section ("goes online for exactly two things"), download
with a QR code for the fixed-name latest APK, install steps, FAQ, link
preview image. Reduced motion shows each chapter's finished picture.
Checked in Chrome at 1440 x 900 and 390 x 844: no console errors, no
sideways scrolling, every playground gesture, and the QR as drawn scans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:13:33 +01:00
TMC c8da9f301c Release 0.13.0 2026-11-12 20:22:17 +01:00
TMCandClaude Fable 5.2 77278a11e0 Hide app from the app list's long-press menu; stop the restart on the first preference write
Hide app: a new entry in the menu a long press on an app opens in the app
list. It adds the app to Lawnchair's hidden list (hidden_apps), which the
list and its search already leave out and which Settings > App drawer >
Hidden apps already shows, so that page is where an app is brought back.
A message with Undo follows. The entry is offered in the app list only
(not for home screen icons, which hiding does not touch) and not while
the home screen is locked.

Preference collectors: five collectors in PreferenceManager2 (icon shape,
dock mode, dock on/off, dock search provider, labels in the dock) had
drop(1) before distinctUntilChanged. The first emission after the dropped
one then always passed, so the first write of any preference after the
launcher started reloaded the model, rebuilt the grid three times and
recreated the launcher. Hiding an app is such a write, made while the
launcher is on screen: the list went blank for a moment and the Undo
message was lost with the old activity. The operators are swapped, so
only a changed value passes.

Checked on the emulator: both menu layouts (labelled rows and the icon
row), hiding right after a fresh start (no relaunch in the log, message
stays), Undo, and unticking the apps under Hidden apps.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 20:21:59 +01:00
TMC 81c5c8914a Release 0.12.0 2026-11-12 18:05:19 +01:00
TMCandClaude Fable 5.2 3b7388c473 Blur behind open folders; hide the icons of a switched-off dock
Folder blur: while a folder is open on the home screen, what is behind it
is blurred, icons and widgets through a RenderEffect and the wallpaper
through the launcher surface's background blur. It is a new input of the
depth controller (folderBlur, 0 to 1), because that controller already
owns both kinds of blur and cleared anything set past it on every frame
of the folder's wallpaper zoom. The folder's spring animation drives it in
step with the dimming, and the places that reset the dimming reset it too.
It works with the wallpaper depth effect switched off, without bringing
the folder's wallpaper zoom along. Setting: Folders > Blur behind open
folders, on by default (flexera_folder_blur). Folders in the app list are
left as they were.

Dock: with the dock switched off the hotseat has no height, but its icons
were still drawn just below the screen edge, and the 97.5% zoom-out behind
an open folder brought their tops into view. One more alpha channel on
the icons hides them.

Checked on the emulator (Android 17 image, window blur available): open,
close by tap outside, Back and Home, an app started from the folder, sleep
and wake, the setting off, and the depth effect off.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 18:01:33 +01:00
TMC 4f687957fb Release 0.11.0 2026-11-12 16:34:02 +01:00
TMCandClaude Fable 5.2 b79579a8dd Clock widget: weather line, font weight, and centring in the widget's own frame
Weather: a clock for which the owner picks a place shows a third line, the
temperature and a word for the sky there. The place is looked up by name in the
clock's options; the data comes from Open-Meteo, which needs no account. It is
off until a place is picked, asks at most every 45 minutes while a clock with
weather is in view, sends only the place's coordinates rounded to about a
kilometre, and needs no location permission. The About screen's note on what
goes online says so.

Font weight: thin, light (as before), regular or bold figures.

Centring: the text stood in the middle of the icons' box, which lies in the
upper part of the widget's cells, so it looked too high inside the outline shown
while editing. It now stands in the middle of the widget's own cells, measured
on what is seen of the text.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 16:33:49 +01:00
TMC 516c4efc56 Release 0.10.0 2026-11-12 15:58:08 +01:00
TMCandClaude Fable 5.2 8e6b737894 Clock widget: smaller by default, with a choice of alignment and size
The first version filled its widget with the figures, which the owner found
huge. The figures are now measured against the icons: small is half an icon
tall, medium (the default) most of an icon, large taller than an icon, with the
widget's own height and width as the limit.

The owner chooses left, center or right and the size from the widget's edit
button. The options are a sheet over the home screen, kept per widget, and each
choice shows on the widget at once.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:57:55 +01:00
TMCandClaude Fable 5.2 31ab0d52e1 Add the Clock widget; the QR code leads to the newest APK
Clock is a second widget that comes with the launcher: the time in large light
figures and the date under it, written onto the wallpaper in the colour of the
icon labels. It follows the phone's 12 or 24 hour setting, updates with the
minute, and a tap opens the phone's clock app. Like Pictures it is drawn by the
launcher itself and keeps to the box of the icons in its cells.

What Pictures and Clock share moved into two places: IconFrame works out that
box, and FlexeraWidgets is where Launcher3's widget code asks about the
launcher's own widgets.

The QR code on the About screen now holds the address of the newest APK, so
that scanning it starts the download; the release page's address stays under it
for typing. For that address to work, the release script uploads each APK a
second time under a name that does not change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:42:52 +01:00
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
73 changed files with 8154 additions and 186 deletions

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
# Flexera Launcher website
A one-page landing site for Flexera Launcher. Plain HTML, CSS and JavaScript: no build step, no
libraries, and nothing is loaded from anyone else's server.
| File | What it is |
| --- | --- |
| `index.html` | The page and all its copy |
| `styles.css` | Layout, colours and the phone's look |
| `script.js` | The phone model, the scroll story, the playground, the scroll effects |
| `assets/` | App icon (WebP and PNG favicons), QR code, link-preview image (`og.jpg`) |
| `fonts/` | Unbounded, Space Grotesk, JetBrains Mono and Outfit, Latin subset, each with its licence |
## Look at it
Open `index.html` in a browser. To see it as a server would serve it:
```sh
cd WEBSITE
python -m http.server 8080
```
and open <http://localhost:8080>.
## After a release
- Change `VERSION` at the top of `script.js`. The page fills every version number from it.
`index.html` carries the same number as a fallback for browsers without JavaScript; replace
`0.13.0` there too.
- The download buttons and the QR code point at
`.../releases/download/latest/FlexeraLauncher.apk`, the fixed-name copy that every release
uploads, so they never need changing.
- If the hero changes, retake `assets/og.jpg` (1200 x 630, the top of the page).
## The phone
The phone is drawn in HTML at 390 x 844 and scaled to fit, four times over: a looping teaser in
the hero, one driven by the scroll position through the eight "How it works" chapters
(`storyState()` in `script.js`), one in the playground that answers to touch and mouse, and the
list phone of the race (the playground's controller, locked to the app list). All of them render
the same state object, described in `blank()`. The 40 app names and icons on it are generic
stand-ins, not real apps.
Other moving parts, all in `script.js`:
- **Wave headline** (`setupWaveTitle`): the hero's letters swell from weight 560 to 900 around the
mouse; without a mouse a wave rolls through on its own.
- **Page alphabet** (`setupPageWave`): one letter per section on the right edge. Hover to see the
wave, drag to jump between sections, tap to scroll there. On phones it shows while scrolling.
- **Detail-card switches** (`setupLook`): left-handed alphabet, comfy rows and icon shape, applied
to every phone on the page.
- **Every layer, pulled apart** (`setupAnatomy`): the story's eighth chapter. The story phone is
swapped for a double built from separate layers (it looks the same closed), which then comes
apart in 3D with a labelled tag per layer and goes back together.
- **Race** (`setupRace`): find an app in a shuffled grid of 40, then in the list with the alphabet.
- **Magnetic buttons and the finger cursor** (`setupPointerFx`), desktop only.
- **Easter eggs**: scrub to F in the playground, or type "flexera" anywhere.
Everything that moves on its own stops for visitors who ask their system for reduced motion.
## Fonts
All four are under the SIL Open Font License 1.1 (`fonts/*-OFL.txt`), downloaded from Google Fonts
and served from this folder.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="41" height="41" class="segno"><path class="qrline" stroke="currentColor" d="M0 0.5h7m2 0h2m1 0h1m2 0h2m2 0h3m1 0h1m4 0h1m2 0h2m1 0h7m-41 1h1m5 0h1m2 0h1m1 0h3m2 0h1m1 0h1m1 0h2m3 0h1m1 0h1m2 0h1m1 0h1m1 0h1m5 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m1 0h3m6 0h3m2 0h3m5 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m2 0h1m4 0h1m3 0h6m6 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m3 0h1m1 0h4m1 0h5m4 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-41 1h1m5 0h1m1 0h1m1 0h4m2 0h2m2 0h3m1 0h1m2 0h1m1 0h1m4 0h1m5 0h1m-41 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-33 1h1m1 0h2m3 0h1m4 0h3m1 0h1m1 0h3m-29 1h1m1 0h5m2 0h1m3 0h1m2 0h2m4 0h5m2 0h1m1 0h1m2 0h5m-38 1h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m1 0h1m1 0h7m1 0h3m-40 1h1m1 0h2m1 0h2m1 0h1m3 0h2m5 0h3m4 0h2m3 0h1m6 0h1m-39 1h3m4 0h1m2 0h1m3 0h2m1 0h2m1 0h4m1 0h3m2 0h2m3 0h2m1 0h1m-39 1h2m2 0h4m2 0h1m2 0h1m1 0h2m4 0h1m1 0h3m2 0h1m1 0h1m4 0h3m-38 1h1m1 0h1m1 0h1m1 0h10m1 0h1m1 0h4m1 0h2m1 0h1m2 0h2m1 0h7m-40 1h1m1 0h1m2 0h1m4 0h4m4 0h1m1 0h1m2 0h4m1 0h2m2 0h1m1 0h1m1 0h1m-37 1h1m5 0h3m2 0h1m1 0h1m3 0h6m2 0h1m1 0h1m2 0h1m2 0h2m1 0h1m2 0h1m-40 1h1m3 0h3m1 0h1m1 0h3m1 0h3m1 0h1m2 0h5m2 0h1m3 0h1m1 0h1m2 0h2m-40 1h4m1 0h1m2 0h1m4 0h3m1 0h5m1 0h1m1 0h2m1 0h5m1 0h4m1 0h2m-41 1h3m2 0h2m2 0h1m3 0h1m3 0h1m3 0h1m2 0h1m2 0h2m3 0h1m3 0h2m-37 1h1m1 0h1m4 0h1m1 0h3m2 0h4m1 0h2m6 0h1m1 0h1m1 0h3m1 0h2m-34 1h4m1 0h1m1 0h1m3 0h2m2 0h1m2 0h5m2 0h1m2 0h1m4 0h2m-39 1h1m1 0h1m5 0h1m5 0h7m2 0h1m3 0h14m-38 1h5m1 0h2m3 0h1m6 0h2m2 0h1m1 0h2m1 0h1m1 0h1m3 0h3m-37 1h2m1 0h1m7 0h1m1 0h2m2 0h1m1 0h4m2 0h2m3 0h1m2 0h1m1 0h1m-36 1h1m2 0h4m8 0h1m2 0h1m4 0h2m2 0h1m5 0h1m-35 1h1m2 0h2m2 0h1m2 0h2m2 0h1m3 0h3m1 0h1m4 0h1m2 0h1m1 0h5m2 0h1m-39 1h1m2 0h2m1 0h1m1 0h1m3 0h1m1 0h1m3 0h1m5 0h2m1 0h2m1 0h4m-36 1h2m1 0h1m1 0h1m1 0h2m2 0h1m1 0h4m3 0h2m1 0h1m2 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m-40 1h1m3 0h3m5 0h2m2 0h1m5 0h4m2 0h1m1 0h2m4 0h3m-40 1h1m3 0h2m4 0h4m4 0h1m2 0h1m1 0h2m3 0h1m1 0h5m1 0h2m2 0h1m-41 1h1m1 0h1m1 0h1m1 0h1m7 0h1m1 0h1m4 0h2m1 0h5m3 0h4m1 0h1m-38 1h1m3 0h2m4 0h2m2 0h2m5 0h1m1 0h2m1 0h3m3 0h3m1 0h2m-38 1h1m1 0h2m2 0h5m6 0h1m6 0h4m1 0h1m2 0h8m-32 1h2m3 0h2m4 0h2m2 0h1m8 0h1m3 0h1m2 0h2m-41 1h7m3 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m3 0h1m1 0h3m1 0h3m1 0h1m1 0h3m-39 1h1m5 0h1m1 0h1m1 0h2m1 0h1m1 0h2m2 0h5m2 0h1m4 0h2m3 0h1m3 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m1 0h1m3 0h1m1 0h4m1 0h5m1 0h1m2 0h5m1 0h2m-40 1h1m1 0h3m1 0h1m1 0h2m1 0h2m1 0h1m2 0h1m2 0h1m2 0h1m1 0h2m6 0h1m1 0h1m1 0h1m1 0h2m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m3 0h5m1 0h1m1 0h1m1 0h1m2 0h2m2 0h6m1 0h1m-39 1h1m5 0h1m2 0h3m2 0h3m1 0h1m1 0h2m1 0h1m2 0h2m2 0h1m1 0h1m1 0h1m1 0h2m1 0h1m-40 1h7m1 0h1m1 0h1m2 0h2m2 0h1m1 0h1m2 0h6m1 0h3m1 0h1m1 0h2m1 0h1"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+476
View File
@@ -0,0 +1,476 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Flexera Launcher · One thumb. Every app.</title>
<meta name="description" content="Flexera is an Android launcher with a calm home screen and an app list you scrub with one thumb. No ads, no account, no tracking.">
<meta name="theme-color" content="#07070d">
<meta property="og:type" content="website">
<meta property="og:title" content="Flexera Launcher · One thumb. Every app.">
<meta property="og:description" content="An Android launcher with a calm home screen and an app list you scrub with one thumb. No ads, no account, no tracking.">
<meta property="og:image" content="assets/og.jpg">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/png" sizes="64x64" href="assets/favicon-64.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png">
<link rel="apple-touch-icon" href="assets/icon-180.png">
<link rel="stylesheet" href="styles.css">
<script src="script.js" defer></script>
</head>
<body>
<!-- Line icons used by the phone and the page. 24 x 24, drawn with strokes. -->
<svg class="sprite" aria-hidden="true" focusable="false">
<symbol id="g-camera" viewBox="0 0 24 24"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></symbol>
<symbol id="g-phone" viewBox="0 0 24 24"><path d="M6.6 3.5l3 3.6-1.8 2.3a12 12 0 0 0 6.8 6.8l2.3-1.8 3.6 3-1.6 2.4c-.5.7-1.4 1-2.2.8C10.3 18.8 5.2 13.7 3.4 7.3c-.2-.8.1-1.7.8-2.2z"/></symbol>
<symbol id="g-msg" viewBox="0 0 24 24"><path d="M4 5h16v11H9.5L4 20z"/></symbol>
<symbol id="g-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5L12 13l8.5-6.5"/></symbol>
<symbol id="g-map" viewBox="0 0 24 24"><path d="M12 21s-6.5-6.2-6.5-11.2a6.5 6.5 0 0 1 13 0C18.5 14.8 12 21 12 21z"/><circle cx="12" cy="9.8" r="2.3"/></symbol>
<symbol id="g-music" viewBox="0 0 24 24"><path d="M9 18V5l11-2v13"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/></symbol>
<symbol id="g-cal" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M8 3v4M16 3v4"/></symbol>
<symbol id="g-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="g-notes" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14H6z"/><path d="M9 11h7M9 15h7M9 7h3"/></symbol>
<symbol id="g-files" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="g-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.5 2.6 3.5 5.5 3.5 8.5s-1 5.9-3.5 8.5c-2.5-2.6-3.5-5.5-3.5-8.5s1-5.9 3.5-8.5z"/></symbol>
<symbol id="g-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5V5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8"/></symbol>
<symbol id="g-wallet" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M15.5 14.5h2"/></symbol>
<symbol id="g-sliders" viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></symbol>
<symbol id="g-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></symbol>
<symbol id="g-pulse" viewBox="0 0 24 24"><path d="M3 12h4l2-4.5 3.5 9 2.5-4.5h6"/></symbol>
<symbol id="g-translate" viewBox="0 0 24 24"><path d="M4 5h9M8.5 3v2M6 5c.6 3 2.5 5.6 5 7M11 5c-.6 3.2-3 6-6.5 7.5"/><path d="M13 21l4-9 4 9M14.5 17.5h5"/></symbol>
<symbol id="g-calc" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8.5 7.5h7M8.5 12h.01M12 12h.01M15.5 12h.01M8.5 16.5h.01M12 16.5h.01M15.5 16.5h.01"/></symbol>
<symbol id="g-wave" viewBox="0 0 24 24"><path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/></symbol>
<symbol id="g-book" viewBox="0 0 24 24"><path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3z"/><path d="M5 17a3 3 0 0 1 3-3h11"/></symbol>
<symbol id="g-news" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h6M7 13h10M7 16h7"/></symbol>
<symbol id="g-play" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10 9v6l5-3z"/></symbol>
<symbol id="g-bag" viewBox="0 0 24 24"><path d="M5 8h14l-1 12H6z"/><path d="M9 8a3 3 0 0 1 6 0"/></symbol>
<symbol id="g-bank" viewBox="0 0 24 24"><path d="M3 9l9-5 9 5M5 10v7M9.5 10v7M14.5 10v7M19 10v7M3 20h18"/></symbol>
<symbol id="g-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 5-3 8.5-7 10-4-1.5-7-5-7-10V6z"/><path d="M9 12l2 2 4-4"/></symbol>
<symbol id="g-person" viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.5"/><path d="M5 20c.8-3.8 3.6-6 7-6s6.2 2.2 7 6"/></symbol>
<symbol id="g-image" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 16l5-5 4 4 3-3 6 6"/><circle cx="15.5" cy="8.5" r="1.5"/></symbol>
<symbol id="g-plane" viewBox="0 0 24 24"><path d="M2.5 13.5l19-8-6.5 15-3-6.5z"/></symbol>
<symbol id="g-moon" viewBox="0 0 24 24"><path d="M19 14.5A7.5 7.5 0 1 1 9.5 5a6 6 0 0 0 9.5 9.5z"/></symbol>
<symbol id="g-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/></symbol>
<symbol id="g-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5.5M12 7.6v.01"/></symbol>
<symbol id="g-widgets" viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><path d="M16.5 3.5L20 7l-3.5 3.5L13 7z"/></symbol>
<symbol id="g-edit" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z"/></symbol>
<symbol id="g-eyeoff" viewBox="0 0 24 24"><path d="M9.9 5.2C10.6 5.07 11.3 5 12 5c4.2 0 7.7 2.6 9.5 7-.6 1.4-1.4 2.6-2.4 3.6M16.4 17.6C15.1 18.5 13.6 19 12 19c-4.2 0-7.7-2.6-9.5-7 .8-2 2-3.6 3.5-4.8"/><path d="M14.1 14.1A3 3 0 0 1 9.9 9.9"/><path d="M4 4l16 16"/></symbol>
<symbol id="g-flip" viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3"/><path d="M18 3v4h-4M6 21v-4h4"/></symbol>
<symbol id="g-arrow" viewBox="0 0 24 24"><path d="M4 12h15M13 6l6 6-6 6"/></symbol>
<symbol id="g-down" viewBox="0 0 24 24"><path d="M12 4v15M6 13l6 6 6-6"/></symbol>
<symbol id="g-up" viewBox="0 0 24 24"><path d="M12 20V5M6 11l6-6 6 6"/></symbol>
<symbol id="g-alarm" viewBox="0 0 24 24"><circle cx="12" cy="13" r="7"/><path d="M12 10v3.2l2.2 1.6M5 4L2.8 6.2M19 4l2.2 2.2"/></symbol>
<symbol id="g-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/></symbol>
<symbol id="g-game" viewBox="0 0 24 24"><rect x="2.5" y="7" width="19" height="10" rx="5"/><path d="M7 10.5v3M5.5 12h3M15.5 11h.01M17.5 13h.01"/></symbol>
<symbol id="g-heart" viewBox="0 0 24 24"><path d="M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"/></symbol>
<symbol id="g-ruler" viewBox="0 0 24 24"><path d="M3 16.5L16.5 3 21 7.5 7.5 21z"/><path d="M7 12.5l1.5 1.5M10 9.5l1.5 1.5M13 6.5l1.5 1.5"/></symbol>
<symbol id="g-radio" viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="12" rx="2"/><circle cx="15.5" cy="14" r="2.5"/><path d="M7 12h3M7 15h3M6 8l11-4"/></symbol>
<symbol id="g-chart" viewBox="0 0 24 24"><path d="M3 17l5-5 4 3 8-8"/><path d="M15 7h5v5"/></symbol>
<symbol id="g-check" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="4"/><path d="M8 12.5l2.5 2.5L16 9.5"/></symbol>
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="g-star" viewBox="0 0 24 24"><path class="fill" d="M12 1.5c.7 5.6 4.9 9.8 10.5 10.5-5.6.7-9.8 4.9-10.5 10.5-.7-5.6-4.9-9.8-10.5-10.5C7.1 11.3 11.3 7.1 12 1.5z"/></symbol>
</svg>
<a class="skip" href="#main">Skip to content</a>
<header class="nav" id="top">
<a class="nav__brand" href="#top" aria-label="Flexera Launcher, back to top">
<img src="assets/flexera-icon-256.webp" alt="" width="40" height="40">
<span>Flexera</span>
</a>
<nav class="nav__links" aria-label="Sections">
<a href="#how">How it works</a>
<a href="#details">Details</a>
<a href="#privacy">Privacy</a>
<a href="#play">Play</a>
<a href="#race">Race</a>
</nav>
<a class="btn btn--sm btn--pink" href="#get">Get the APK</a>
<div class="nav__progress" aria-hidden="true"><i></i></div>
</header>
<!-- The page's own wave alphabet: one letter per section. Drag it like the launcher's. -->
<nav class="pagewave" aria-label="Jump to a section" data-pagewave>
<a href="#top" data-label="Flexera">F</a>
<a href="#how" data-label="How it works">H</a>
<a href="#anatomy" data-label="Under the glass">U</a>
<a href="#details" data-label="Details">D</a>
<a href="#privacy" data-label="Privacy">P</a>
<a href="#play" data-label="Touch it">T</a>
<a href="#race" data-label="Race">R</a>
<a href="#get" data-label="Get it">G</a>
<span class="pagewave__bubble" aria-hidden="true"><b>F</b><i>Flexera</i></span>
</nav>
<main id="main">
<!-- HERO -->
<section class="hero" aria-labelledby="hero-title">
<div class="hero__ghost" aria-hidden="true" data-parallax="0.18">Flexera</div>
<div class="hero__grid">
<div class="hero__copy">
<p class="kicker rv"><span class="kicker__dot"></span>Android launcher <i>/</i> v<span data-version>0.13.0</span> <i>/</i> free</p>
<h1 class="hero__title rv" id="hero-title" data-wave>
<span class="line"><span class="line__in">One thumb.</span></span>
<span class="line"><span class="line__in grad">Every app.</span></span>
</h1>
<p class="lead rv">Flexera is a home screen for Android that stays calm, with an app list you fly through using one thumb. No ads. No account. Nothing about you leaves your phone.</p>
<div class="cta rv">
<a class="btn btn--big btn--cyan" href="https://git.mariosem.es/msemes/Flexera-Launcher-Releases/releases/download/latest/FlexeraLauncher.apk" data-apk>
Download the APK <svg class="gly" aria-hidden="true"><use href="#g-down"/></svg>
</a>
<a class="btn btn--big btn--ghost" href="#how">
See it move <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg>
</a>
</div>
<p class="meta rv">Android 8.0 or newer <i>·</i> Updates itself <i>·</i> No Play Store needed</p>
</div>
<div class="hero__stage" aria-hidden="true">
<div class="hero__slab"></div>
<div class="hero__tilt" data-tilt>
<div class="phone-host" data-phone="hero"></div>
</div>
<span class="sticker sticker--a">Wave<br>alphabet</span>
<span class="sticker sticker--b">0 trackers</span>
<div class="spinner">
<svg viewBox="0 0 120 120" class="spinner__ring">
<defs><path id="spin-path" d="M60,60 m-45,0 a45,45 0 1,1 90,0 a45,45 0 1,1 -90,0"/></defs>
<text><textPath href="#spin-path">Swipe up · Scrub · Launch · Repeat · </textPath></text>
</svg>
<svg class="gly spinner__arrow"><use href="#g-down"/></svg>
</div>
</div>
</div>
</section>
<div class="bands" aria-hidden="true">
<div class="marquee marquee--cyan">
<div class="marquee__track" data-marquee="70">
<span>One-handed</span><span>No ads</span><span>No account</span><span>No tracking</span><span>Wave alphabet</span><span>Blur folders</span><span>Quick launch</span><span>Updates itself</span>
</div>
</div>
<div class="marquee marquee--pink">
<div class="marquee__track" data-marquee="-55">
<span>Swipe up</span><span>Scrub</span><span>Launch</span><span>Repeat</span><span>Swipe up</span><span>Scrub</span><span>Launch</span><span>Repeat</span>
</div>
</div>
</div>
<!-- STORY -->
<section class="story" id="how" aria-labelledby="how-title">
<header class="section-head">
<p class="tag rv">[ 01 ] How it works</p>
<h2 class="h2 rv" id="how-title">
<span class="line"><span class="line__in">Keep scrolling.</span></span>
<span class="line"><span class="line__in grad">The phone talks.</span></span>
</h2>
</header>
<div class="story__body">
<div class="story__stage" aria-hidden="true">
<div class="story__num" data-story-num>01</div>
<div class="story__phones">
<div class="phone-host" data-phone="story"></div>
<div class="xhost" data-xphone></div>
</div>
<svg class="anatomy__lines" data-xlines></svg>
<ol class="anatomy__labels" data-xlabels>
<li><i>05</i><b>Glass</b><span>No feed. No ads.</span></li>
<li><i>04</i><b>App list</b><span>A to Z + wave alphabet</span></li>
<li><i>03</i><b>Icons &amp; folders</b><span>Blur behind folders</span></li>
<li><i>02</i><b>Widgets</b><span>Clock, weather, pictures</span></li>
<li><i>01</i><b>Wallpaper</b><span>Yours. Untouched.</span></li>
</ol>
<ol class="story__rail" data-story-rail>
<li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li>
</ol>
</div>
<div class="story__steps">
<article class="step" data-step>
<div class="step__card">
<p class="step__no">01 / 08</p>
<h3 class="step__title">Home, minus the noise.</h3>
<p class="step__text">Pages, folders and widgets, in the Android layout you already know. No feed, no ads, nothing you didn't put there yourself.</p>
<p class="step__gesture"><span>Gesture</span> None. Just look.</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">02 / 08</p>
<h3 class="step__title">Swipe up. A&nbsp;to&nbsp;Z.</h3>
<p class="step__text">Every app in one clean column, straight over your wallpaper. New messages show up under the app's name, so you see them before you open anything.</p>
<p class="step__gesture"><span>Gesture</span> Swipe up</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">03 / 08</p>
<h3 class="step__title">Ride the wave.</h3>
<p class="step__text">Slide your thumb along the letters on the edge. They swell around it like a wave, and the list drops that letter into the middle of the screen, right where your thumb already is. Swipe up and keep holding to scrub straight from home.</p>
<p class="step__gesture"><span>Gesture</span> Slide on the edge</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">04 / 08</p>
<h3 class="step__title">Folders in focus.</h3>
<p class="step__text">Open a folder and the rest of the screen melts into a soft blur, wallpaper included. Just the apps you came for.</p>
<p class="step__gesture"><span>Gesture</span> Tap</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">05 / 08</p>
<h3 class="step__title">Swipe into nothing.</h3>
<p class="step__text">There's no page left of your first one. Swipe there anyway and your camera opens. Or any app, or any shortcut you pick. Works past your last page too.</p>
<p class="step__gesture"><span>Gesture</span> Swipe past the edge</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">06 / 08</p>
<h3 class="step__title">Widgets that fit in.</h3>
<p class="step__text">A clock that lines up with your icons: the date, a one-line forecast, left, center or right, thin to bold. Next to it, a picture frame in your icon shape that turns to the next photo every ten seconds.</p>
<p class="step__gesture"><span>Gesture</span> Hold, then edit</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">07 / 08</p>
<h3 class="step__title">Out of sight.</h3>
<p class="step__text">Hold any app in the list and tap Hide app. Gone from the list and from search. Changed your mind? Undo, or bring it back from Settings whenever you like.</p>
<p class="step__gesture"><span>Gesture</span> Hold, then hide</p>
</div>
</article>
<article class="step step--long" data-step id="anatomy">
<div class="step__card">
<p class="step__no">08 / 08</p>
<h3 class="step__title">Every layer. <span class="grad">Pulled apart.</span></h3>
<p class="step__text">Under the glass sit five layers: your wallpaper, the widgets, icons and folders, the app list with its wave alphabet, and the glass on top. No feed and no ads hiding in between.</p>
<p class="step__gesture"><span>Gesture</span> Keep scrolling</p>
</div>
</article>
</div>
</div>
</section>
<!-- DETAILS -->
<section class="details" id="details" aria-labelledby="details-title">
<header class="section-head">
<p class="tag rv">[ 02 ] The details</p>
<h2 class="h2 rv" id="details-title">
<span class="line"><span class="line__in">Small things.</span></span>
<span class="line"><span class="line__in grad">Done properly.</span></span>
</h2>
</header>
<p class="cards__note rv">Cards 01, 02 and 05 are switches. Try them: they restyle every phone on this page.</p>
<div class="cards">
<article class="card card--ink rv">
<div class="card__art art-flip" aria-hidden="true"><i class="art-flip__phone"><b>A B C D E F G</b></i></div>
<span class="card__no">01</span>
<h3>Lefty? Flip it.</h3>
<p>Move the alphabet to the left edge. One switch, and the whole list follows your other thumb.</p>
<button class="card__try" type="button" data-look="lefty" aria-pressed="false">Flip the alphabet</button>
</article>
<article class="card card--cyan rv">
<div class="card__art art-rows" aria-hidden="true"><i class="art-rows__col"><b></b><b></b><b></b><b></b><b></b></i><i class="art-rows__col art-rows__col--comfy"><b></b><b></b><b></b></i></div>
<span class="card__no">02</span>
<h3>Compact or comfy.</h3>
<p>Pick how tall the rows in your app list are. More apps on screen, or bigger targets.</p>
<div class="card__seg" role="group" aria-label="Row size">
<button type="button" data-look="comfy" data-v="0" aria-pressed="true">Compact</button>
<button type="button" data-look="comfy" data-v="1" aria-pressed="false">Comfy</button>
</div>
</article>
<article class="card card--violet rv">
<div class="card__art art-update" aria-hidden="true"><span class="art-update__v">v<span data-version>0.13.0</span></span><i class="art-update__bar"><b></b></i></div>
<span class="card__no">03</span>
<h3>Updates itself.</h3>
<p>About once a day it checks for a new version and installs it when you say so. No store needed.</p>
</article>
<article class="card card--pink rv">
<div class="card__art art-move" aria-hidden="true"><i class="art-move__box">SL</i><svg class="gly art-move__arrow"><use href="#g-arrow"/></svg><i class="art-move__box art-move__box--f"><img src="assets/flexera-icon-256.webp" alt="" width="44" height="44"></i></div>
<span class="card__no">04</span>
<h3>Move in, one tap.</h3>
<p>Coming from Smart Launcher? Import your home screen from its backup: layout, folders and your photo widget.</p>
</article>
<article class="card card--ink rv">
<div class="card__art art-shapes" role="group" aria-label="Icon shape">
<button type="button" data-shape="squircle" aria-pressed="true" aria-label="Squircle"><i></i></button>
<button type="button" data-shape="circle" aria-pressed="false" aria-label="Circle"><i></i></button>
<button type="button" data-shape="square" aria-pressed="false" aria-label="Square"><i></i></button>
<button type="button" data-shape="teardrop" aria-pressed="false" aria-label="Teardrop"><i></i></button>
</div>
<span class="card__no">05</span>
<h3>Make it yours.</h3>
<p>Icon packs, icon shapes, themed icons and colors. Labels on or off. Your grid, your rules. Tap a shape above.</p>
</article>
<article class="card card--cyan rv">
<div class="card__art art-tap" aria-hidden="true"><i></i><i></i></div>
<span class="card__no">06</span>
<h3>Gestures for everything.</h3>
<p>Double-tap to lock, swipe down for notifications, and more. Every gesture is yours to set.</p>
</article>
</div>
<div class="stats">
<div class="stat rv"><b>0</b><span>Ads</span></div>
<div class="stat rv"><b>0</b><span>Accounts</span></div>
<div class="stat rv"><b>2</b><span>Reasons to<br>go online</span></div>
<div class="stat rv"><b>1</b><span>Thumb<br>needed</span></div>
</div>
</section>
<!-- PRIVACY -->
<section class="privacy" id="privacy" aria-labelledby="privacy-title">
<p class="tag tag--ink rv">[ 03 ] Privacy</p>
<h2 class="privacy__title rv" id="privacy-title">It goes online for <span class="privacy__hl">exactly two</span> things.</h2>
<div class="privacy__list">
<div class="pitem rv">
<span class="pitem__no">1</span>
<h3>Updates</h3>
<p>About once a day it asks its own release page whether there's a newer version. Installing it is your call.</p>
</div>
<div class="pitem rv">
<span class="pitem__no">2</span>
<h3>Weather, if you ask</h3>
<p>Pick a town for the clock's weather line and it asks Open-Meteo about that place. Only that place. It never uses your location.</p>
</div>
</div>
<p class="privacy__end rv">Nothing else about you, your apps or your phone leaves it.</p>
<p class="privacy__note rv">This page plays by the same rules: no cookies, no analytics, no fonts or scripts from anyone else.</p>
</section>
<!-- PLAYGROUND -->
<section class="play" id="play" aria-labelledby="play-title">
<header class="section-head">
<p class="tag rv">[ 04 ] Playground</p>
<h2 class="h2 rv" id="play-title">
<span class="line"><span class="line__in">Go on.</span></span>
<span class="line"><span class="line__in grad">Touch it.</span></span>
</h2>
</header>
<div class="play__grid">
<div class="play__stage">
<div class="phone-host" data-phone="play" aria-label="A working model of Flexera Launcher. Use the buttons beside it if you can't use touch or a mouse." role="img"></div>
</div>
<div class="play__side">
<p class="play__caption" aria-live="polite" data-caption>Swipe up on the home screen to open your apps.</p>
<div class="chips" role="group" aria-label="Show me">
<button class="chip" type="button" data-demo="drawer"><svg class="gly" aria-hidden="true"><use href="#g-up"/></svg>Swipe up</button>
<button class="chip" type="button" data-demo="scrub"><svg class="gly" aria-hidden="true"><use href="#g-wave"/></svg>Scrub the letters</button>
<button class="chip" type="button" data-demo="folder"><svg class="gly" aria-hidden="true"><use href="#g-files"/></svg>Open a folder</button>
<button class="chip" type="button" data-demo="hide"><svg class="gly" aria-hidden="true"><use href="#g-eyeoff"/></svg>Hide an app</button>
<button class="chip" type="button" data-demo="quick"><svg class="gly" aria-hidden="true"><use href="#g-camera"/></svg>Quick launch</button>
<button class="chip chip--ghost" type="button" data-demo="reset">Reset</button>
</div>
<ul class="howto">
<li><b>Swipe up</b> on the home screen</li>
<li><b>Drag</b> along the letters on the right</li>
<li><b>Tap</b> a folder, then outside it</li>
<li><b>Hold</b> an app in the list</li>
<li><b>Swipe right</b> on the first page</li>
<li><b>Hold</b> the clock to restyle it</li>
<li><b>Swipe up</b> from the bottom edge to go home</li>
<li><b>Scrub to F</b> for a surprise</li>
</ul>
</div>
</div>
</section>
<!-- RACE -->
<section class="race" id="race" aria-labelledby="race-title">
<header class="section-head">
<p class="tag rv">[ 05 ] Race</p>
<h2 class="h2 rv" id="race-title">
<span class="line"><span class="line__in">Find it.</span></span>
<span class="line"><span class="line__in grad">Faster.</span></span>
</h2>
</header>
<div class="race__grid">
<div class="race__stage" data-race-stage data-phase="idle">
<p class="race__pill" aria-hidden="true">Find <b data-race-pill>Podcasts</b></p>
<div class="phone-host race__host race__host--grid" data-race-grid></div>
<div class="phone-host race__host race__host--list" data-phone="race"></div>
<div class="race__overlay" data-race-overlay aria-hidden="true">
<p class="race__round" data-race-round>Round 1</p>
<p class="race__msg" data-race-msg>A grid of 40 apps. Find the one named beside it, as fast as you can.</p>
</div>
</div>
<div class="race__side">
<p class="race__find">Find <b data-race-target>Podcasts</b></p>
<div class="race__times">
<div class="race__time"><span>Round 1<i>A grid of 40 apps</i></span><b data-race-t1>&ndash;</b></div>
<div class="race__time"><span>Round 2<i>Flexera's list and wave alphabet</i></span><b data-race-t2>&ndash;</b></div>
</div>
<p class="race__verdict" aria-live="polite" data-race-verdict>Two rounds, the same 40 apps. In round 2, drag along the letters on the edge.</p>
<button class="btn btn--big btn--pink" type="button" data-race-start>Start the race <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg></button>
</div>
</div>
</section>
<!-- GET IT -->
<section class="get" id="get" aria-labelledby="get-title">
<p class="tag tag--ink rv">[ 06 ] Get it</p>
<h2 class="get__title rv" id="get-title">
<span class="line"><span class="line__in">Get</span></span>
<span class="line"><span class="line__in">Flexera.</span></span>
</h2>
<div class="get__grid">
<figure class="qr rv">
<svg class="qr__code" viewBox="-4 -4 49 49" role="img" aria-label="QR code that downloads the newest Flexera Launcher APK"><path class="qrline" stroke="currentColor" d="M0 0.5h7m2 0h2m1 0h1m2 0h2m2 0h3m1 0h1m4 0h1m2 0h2m1 0h7m-41 1h1m5 0h1m2 0h1m1 0h3m2 0h1m1 0h1m1 0h2m3 0h1m1 0h1m2 0h1m1 0h1m1 0h1m5 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m1 0h3m6 0h3m2 0h3m5 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m2 0h1m4 0h1m3 0h6m6 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m3 0h1m1 0h4m1 0h5m4 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-41 1h1m5 0h1m1 0h1m1 0h4m2 0h2m2 0h3m1 0h1m2 0h1m1 0h1m4 0h1m5 0h1m-41 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-33 1h1m1 0h2m3 0h1m4 0h3m1 0h1m1 0h3m-29 1h1m1 0h5m2 0h1m3 0h1m2 0h2m4 0h5m2 0h1m1 0h1m2 0h5m-38 1h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m1 0h1m1 0h7m1 0h3m-40 1h1m1 0h2m1 0h2m1 0h1m3 0h2m5 0h3m4 0h2m3 0h1m6 0h1m-39 1h3m4 0h1m2 0h1m3 0h2m1 0h2m1 0h4m1 0h3m2 0h2m3 0h2m1 0h1m-39 1h2m2 0h4m2 0h1m2 0h1m1 0h2m4 0h1m1 0h3m2 0h1m1 0h1m4 0h3m-38 1h1m1 0h1m1 0h1m1 0h10m1 0h1m1 0h4m1 0h2m1 0h1m2 0h2m1 0h7m-40 1h1m1 0h1m2 0h1m4 0h4m4 0h1m1 0h1m2 0h4m1 0h2m2 0h1m1 0h1m1 0h1m-37 1h1m5 0h3m2 0h1m1 0h1m3 0h6m2 0h1m1 0h1m2 0h1m2 0h2m1 0h1m2 0h1m-40 1h1m3 0h3m1 0h1m1 0h3m1 0h3m1 0h1m2 0h5m2 0h1m3 0h1m1 0h1m2 0h2m-40 1h4m1 0h1m2 0h1m4 0h3m1 0h5m1 0h1m1 0h2m1 0h5m1 0h4m1 0h2m-41 1h3m2 0h2m2 0h1m3 0h1m3 0h1m3 0h1m2 0h1m2 0h2m3 0h1m3 0h2m-37 1h1m1 0h1m4 0h1m1 0h3m2 0h4m1 0h2m6 0h1m1 0h1m1 0h3m1 0h2m-34 1h4m1 0h1m1 0h1m3 0h2m2 0h1m2 0h5m2 0h1m2 0h1m4 0h2m-39 1h1m1 0h1m5 0h1m5 0h7m2 0h1m3 0h14m-38 1h5m1 0h2m3 0h1m6 0h2m2 0h1m1 0h2m1 0h1m1 0h1m3 0h3m-37 1h2m1 0h1m7 0h1m1 0h2m2 0h1m1 0h4m2 0h2m3 0h1m2 0h1m1 0h1m-36 1h1m2 0h4m8 0h1m2 0h1m4 0h2m2 0h1m5 0h1m-35 1h1m2 0h2m2 0h1m2 0h2m2 0h1m3 0h3m1 0h1m4 0h1m2 0h1m1 0h5m2 0h1m-39 1h1m2 0h2m1 0h1m1 0h1m3 0h1m1 0h1m3 0h1m5 0h2m1 0h2m1 0h4m-36 1h2m1 0h1m1 0h1m1 0h2m2 0h1m1 0h4m3 0h2m1 0h1m2 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m-40 1h1m3 0h3m5 0h2m2 0h1m5 0h4m2 0h1m1 0h2m4 0h3m-40 1h1m3 0h2m4 0h4m4 0h1m2 0h1m1 0h2m3 0h1m1 0h5m1 0h2m2 0h1m-41 1h1m1 0h1m1 0h1m1 0h1m7 0h1m1 0h1m4 0h2m1 0h5m3 0h4m1 0h1m-38 1h1m3 0h2m4 0h2m2 0h2m5 0h1m1 0h2m1 0h3m3 0h3m1 0h2m-38 1h1m1 0h2m2 0h5m6 0h1m6 0h4m1 0h1m2 0h8m-32 1h2m3 0h2m4 0h2m2 0h1m8 0h1m3 0h1m2 0h2m-41 1h7m3 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m3 0h1m1 0h3m1 0h3m1 0h1m1 0h3m-39 1h1m5 0h1m1 0h1m1 0h2m1 0h1m1 0h2m2 0h5m2 0h1m4 0h2m3 0h1m3 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m1 0h1m3 0h1m1 0h4m1 0h5m1 0h1m2 0h5m1 0h2m-40 1h1m1 0h3m1 0h1m1 0h2m1 0h2m1 0h1m2 0h1m2 0h1m2 0h1m1 0h2m6 0h1m1 0h1m1 0h1m1 0h2m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m3 0h5m1 0h1m1 0h1m1 0h1m2 0h2m2 0h6m1 0h1m-39 1h1m5 0h1m2 0h3m2 0h3m1 0h1m1 0h2m1 0h1m2 0h2m2 0h1m1 0h1m1 0h1m1 0h2m1 0h1m-40 1h7m1 0h1m1 0h1m2 0h2m2 0h1m1 0h1m2 0h6m1 0h3m1 0h1m1 0h2m1 0h1"/></svg>
<figcaption>Scan with your phone's camera</figcaption>
</figure>
<div class="get__main">
<a class="btn btn--huge btn--pink rv" href="https://git.mariosem.es/msemes/Flexera-Launcher-Releases/releases/download/latest/FlexeraLauncher.apk" data-apk>
Download the APK <svg class="gly" aria-hidden="true"><use href="#g-down"/></svg>
</a>
<p class="meta meta--ink rv">Version <span data-version>0.13.0</span> <i>·</i> Android 8.0 or newer <i>·</i> Free, no ads</p>
<ol class="steps rv">
<li><b>Download</b> the APK on your Android phone.</li>
<li><b>Open it.</b> When Android asks, let your browser install apps.</li>
<li><b>Make it home.</b> Press Home and pick Flexera, or go to Settings &rarr; Apps &rarr; Default apps &rarr; Home app.</li>
</ol>
<a class="textlink rv" href="https://git.mariosem.es/msemes/Flexera-Launcher-Releases/releases">Every release and its notes <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg></a>
</div>
</div>
<div class="faq">
<details class="rv">
<summary>Why isn't it on Google Play?</summary>
<p>It's a small independent project, shipped straight from its own release page. The app checks that page for new versions, so you won't miss updates.</p>
</details>
<details class="rv">
<summary>Android warns me before installing. Is that normal?</summary>
<p>Yes. Android double-checks every app installed from outside the Play Store. Play Protect may also mention that the app can ask for notification access (for notification dots and previews) and accessibility (only for gestures such as double-tap to lock). Both stay off until you turn them on yourself.</p>
</details>
<details class="rv">
<summary>Will it work on my phone?</summary>
<p>It runs on Android 8.0 and newer. The blur behind folders needs Android 12 or newer; on older versions folders open over a plain dimmed background.</p>
</details>
<details class="rv">
<summary>Can I go back to my old launcher?</summary>
<p>Any time. Settings &rarr; Apps &rarr; Default apps &rarr; Home app, and pick the old one. Nothing is deleted.</p>
</details>
<details class="rv">
<summary>What does it cost?</summary>
<p>Nothing. No ads, no in-app purchases, no account.</p>
</details>
</div>
</section>
</main>
<footer class="foot">
<div class="foot__word" aria-hidden="true">Flexera</div>
<div class="foot__row">
<p>Flexera Launcher <span data-version>0.13.0</span></p>
<p>Built on Lawnchair and Android's Launcher3.</p>
<p>&copy; 2026 Flexera Launcher</p>
<a class="textlink" href="#top">Back to top <svg class="gly" aria-hidden="true"><use href="#g-up"/></svg></a>
</div>
</footer>
</body>
</html>
+2073
View File
File diff suppressed because it is too large. Load diff
+930
View File
@@ -0,0 +1,930 @@
/* Flexera Launcher landing page.
Brutal layout (hard 2px lines, solid slabs, offset shadows) in the app icon's neon colours.
Fonts are served from this folder: nothing is loaded from anyone else. */
@font-face { font-family: "Unbounded"; src: url("fonts/unbounded.woff2") format("woff2"); font-weight: 300 900; font-display: swap; }
@font-face { font-family: "Space Grotesk"; src: url("fonts/space-grotesk.woff2") format("woff2"); font-weight: 300 700; font-display: swap; }
@font-face { font-family: "JetBrains Mono"; src: url("fonts/jetbrains-mono.woff2") format("woff2"); font-weight: 400 800; font-display: swap; }
@font-face { font-family: "Outfit"; src: url("fonts/outfit.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
:root {
--bg: #07070d;
--bg-2: #0e0d1d;
--fg: #f5f4ff;
--dim: #b4b2d2;
--ink: #07070d;
/* From the icon: ice and cyan at the top of the F, electric blue, deep indigo, violet, hot pink. */
--ice: #76fafc;
--cyan: #1ddbfd;
--blue: #417df4;
--indigo: #0d0b72;
--violet: #653df0;
--purple: #9c0ff6;
--pink: #fc59fb;
--grad: linear-gradient(95deg, #76fafc 0%, #1ddbfd 16%, #417df4 40%, #653df0 64%, #c866fa 82%, #fc59fb 100%);
--display: "Unbounded", "Arial Black", system-ui, sans-serif;
--body: "Space Grotesk", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", monospace;
--ui: "Outfit", system-ui, sans-serif;
--pad: clamp(16px, 4vw, 56px);
--b: 2px;
--max: 1520px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 400 17px/1.55 var(--body);
-webkit-font-smoothing: antialiased;
overflow-x: clip;
}
/* Film grain over everything, very faint. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 90;
opacity: .055;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
img, svg { display: block; }
a { color: inherit; }
::selection { background: var(--pink); color: var(--ink); }
:focus-visible { outline: 3px solid var(--cyan); outline-offset: 3px; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.gly { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.gly .fill, .fill { fill: currentColor; stroke: none; }
.skip {
position: absolute; left: 12px; top: -60px; z-index: 200;
background: var(--cyan); color: var(--ink); padding: 10px 14px; font: 700 14px var(--mono);
border: var(--b) solid var(--ink); text-decoration: none;
}
.skip:focus { top: 12px; }
/* ---------- type helpers ---------- */
.grad {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tag {
margin: 0;
font: 700 13px/1 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--cyan);
}
.tag--ink { color: var(--ink); }
.h2 {
margin: .35em 0 0;
font: 900 clamp(40px, 7.2vw, 118px)/.9 var(--display);
letter-spacing: -.04em;
text-transform: uppercase;
}
/* Lines that slide up out of a mask when their heading scrolls into view. */
.line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.line__in { display: block; transform: translateY(108%); transition: transform 1s cubic-bezier(.2, .8, .1, 1); }
.line + .line .line__in { transition-delay: .09s; }
.in .line__in { transform: none; }
.rv:not(.hero__title):not(.h2):not(.get__title) { opacity: 0; transform: translateY(26px); transition: opacity .8s ease, transform .9s cubic-bezier(.2, .8, .1, 1); }
.rv.in:not(.hero__title):not(.h2):not(.get__title) { opacity: 1; transform: none; }
.meta { margin: 18px 0 0; font: 500 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
.meta i { font-style: normal; color: var(--pink); margin: 0 .35em; }
.meta--ink { color: var(--ink); }
.meta--ink i { color: var(--ink); }
/* ---------- buttons ---------- */
.btn {
--c: var(--cyan);
--sh: var(--fg);
display: inline-flex;
align-items: center;
gap: .7em;
padding: 1.05em 1.45em;
font: 800 15px/1 var(--display);
letter-spacing: .01em;
text-transform: uppercase;
text-decoration: none;
color: var(--ink);
background: var(--c);
border: var(--b) solid var(--fg);
box-shadow: 6px 6px 0 var(--sh);
transition: transform .14s ease, box-shadow .14s ease, background .2s;
cursor: pointer;
}
.btn .gly { width: 1.25em; height: 1.25em; stroke-width: 2.6; }
.btn:hover { transform: translate(-3px, -3px); box-shadow: 9px 9px 0 var(--sh); }
.btn:active { transform: translate(4px, 4px); box-shadow: 2px 2px 0 var(--sh); }
.btn--pink { --c: var(--pink); }
.btn--cyan { --c: var(--cyan); }
.btn--ghost { background: transparent; color: var(--fg); --sh: var(--violet); }
.btn--sm { padding: .8em 1.05em; font-size: 12px; box-shadow: 4px 4px 0 var(--sh); }
.btn--sm:hover { box-shadow: 6px 6px 0 var(--sh); }
.btn--big { font-size: clamp(14px, 1.1vw, 17px); }
.btn--huge { font-size: clamp(18px, 2.2vw, 30px); padding: 1em 1.3em; border-color: var(--ink); --sh: var(--ink); }
.textlink {
display: inline-flex; align-items: center; gap: .5em;
font: 700 14px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
text-decoration: none; border-bottom: var(--b) solid currentColor; padding-bottom: 4px;
}
.textlink:hover { color: var(--pink); }
/* ---------- nav ---------- */
.nav {
position: fixed; inset: 0 0 auto 0; z-index: 80;
display: flex; align-items: center; gap: 28px;
padding: 14px var(--pad);
transition: background .3s, border-color .3s;
border-bottom: var(--b) solid transparent;
}
.nav.is-scrolled { background: rgba(7, 7, 13, .86); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.nav__brand { display: flex; align-items: center; gap: 12px; text-decoration: none; font: 900 18px/1 var(--display); text-transform: uppercase; letter-spacing: -.02em; }
.nav__brand img { width: 40px; height: 40px; }
.nav__links { display: flex; gap: 26px; margin-left: auto; }
.nav__links a { font: 600 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; text-decoration: none; padding: 6px 0; border-bottom: var(--b) solid transparent; }
.nav__links a:hover { border-bottom-color: var(--cyan); color: var(--cyan); }
.nav__progress { position: absolute; left: 0; right: 0; bottom: -2px; height: 3px; }
.nav__progress i { display: block; height: 100%; background: var(--grad); transform-origin: 0 50%; transform: scaleX(0); }
/* ---------- hero ---------- */
.hero {
position: relative;
min-height: 100vh;
min-height: 100svh;
padding: 120px var(--pad) 70px;
overflow: hidden;
background:
radial-gradient(60% 50% at 85% 30%, rgba(101, 61, 240, .35), transparent 70%),
radial-gradient(40% 40% at 10% 90%, rgba(29, 219, 253, .14), transparent 70%),
var(--bg);
}
.hero::before { /* the layout grid, drawn */
content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background-image:
linear-gradient(to right, rgba(245, 244, 255, .06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(245, 244, 255, .06) 1px, transparent 1px);
background-size: 96px 96px;
mask-image: linear-gradient(to bottom, #000 40%, transparent);
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent);
}
.hero__ghost {
position: absolute; left: -1vw; bottom: -5.5vw;
font: 900 26vw/.8 var(--display); letter-spacing: -.06em; text-transform: uppercase;
/* A faint solid fill, never an outline: the variable font draws its letters from overlapping
shapes, and an outline would trace every overlap. */
background: linear-gradient(180deg, rgba(118, 250, 252, .11), rgba(101, 61, 240, .07) 60%, rgba(252, 89, 251, .05));
-webkit-background-clip: text; background-clip: text; color: transparent;
white-space: nowrap; pointer-events: none; user-select: none;
}
.hero__grid {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(0, .82fr);
gap: clamp(24px, 4vw, 80px);
align-items: center;
max-width: var(--max);
margin: 0 auto;
}
.kicker {
display: inline-flex; align-items: center; gap: 10px; margin: 0;
padding: 9px 14px; border: var(--b) solid var(--fg);
font: 700 12px/1 var(--mono); text-transform: uppercase; letter-spacing: .12em;
}
.kicker i { font-style: normal; color: var(--pink); }
.kicker__dot { width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 4px rgba(29, 219, 253, .2); animation: blink 1.6s steps(2) infinite; }
@keyframes blink { 50% { opacity: .2; } }
.hero__title {
margin: 26px 0 28px;
font: 900 clamp(50px, 8.4vw, 158px)/.86 var(--display);
letter-spacing: -.05em;
text-transform: uppercase;
}
.lead { margin: 0; max-width: 33em; font-size: clamp(18px, 1.45vw, 22px); line-height: 1.5; color: #dddbf3; }
.cta { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 36px; }
.hero__stage { position: relative; display: grid; place-items: center; perspective: 1600px; padding: 20px 0; }
.hero__slab {
position: absolute; inset: 9% 3% 12% 14%;
background: var(--violet);
border: var(--b) solid var(--fg);
box-shadow: 14px 14px 0 var(--pink);
transform: rotate(-5deg);
}
.hero__tilt {
position: relative;
transform: rotateY(var(--ry, -16deg)) rotateX(var(--rx, 8deg)) rotateZ(var(--rz, 3deg));
transform-style: preserve-3d;
will-change: transform;
}
.hero .phone-host { width: min(360px, 74vw); }
.sticker {
position: absolute; z-index: 3;
padding: 11px 14px;
font: 800 14px/1.05 var(--display); text-transform: uppercase; letter-spacing: -.01em;
color: var(--ink); background: var(--cyan);
border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--ink);
animation: bob 4s ease-in-out infinite;
}
.sticker--a { top: 12%; left: -2%; transform: rotate(-9deg); }
.sticker--b { top: 54%; right: -3%; background: var(--pink); transform: rotate(7deg); animation-delay: -2s; }
@keyframes bob { 50% { translate: 0 -10px; } }
.spinner { position: absolute; left: 0; bottom: 4%; width: 128px; height: 128px; z-index: 3; display: grid; place-items: center; }
.spinner__ring { position: absolute; inset: 0; width: 100%; height: 100%; animation: spin 16s linear infinite; }
.spinner__ring text { font: 700 10.5px var(--mono); letter-spacing: .18em; text-transform: uppercase; fill: var(--fg); }
.spinner__arrow { width: 38px; height: 38px; color: var(--ink); background: var(--ice); border-radius: 50%; padding: 8px; stroke-width: 2.8; border: var(--b) solid var(--ink); }
@keyframes spin { to { rotate: 360deg; } }
/* ---------- marquees ---------- */
.bands { position: relative; overflow: hidden; padding-bottom: 30px; } /* the tilted band stays inside */
.marquee { position: relative; overflow: hidden; z-index: 2; }
.marquee--cyan { background: var(--cyan); color: var(--ink); }
.marquee--pink { background: var(--pink); color: var(--ink); transform: rotate(-1.6deg) scale(1.03); margin-top: -4px; border-top: var(--b) solid var(--ink); }
.marquee__track { display: flex; width: max-content; white-space: nowrap; will-change: transform; }
.marquee__track span {
display: inline-flex; align-items: center; gap: .55em; padding: .32em .55em .32em 0;
font: 900 clamp(26px, 3.8vw, 58px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em;
}
.marquee__track span::after {
content: ""; width: .62em; height: .62em; flex: none;
background: currentColor;
clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
}
/* ---------- sections ---------- */
section { position: relative; }
.section-head { max-width: var(--max); margin: 0 auto; padding: clamp(80px, 11vw, 150px) var(--pad) clamp(30px, 4vw, 60px); }
/* ---------- story ---------- */
.story__body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
max-width: var(--max);
margin: 0 auto;
padding: 0 var(--pad);
}
.story__stage {
grid-column: 2; grid-row: 1;
position: sticky; top: 0;
height: 100vh; height: 100svh;
display: grid; place-items: center;
align-self: start;
}
.story__num {
position: absolute; left: 50%; top: 50%; translate: -50% -50%;
font: 900 clamp(180px, 30vw, 480px)/1 var(--display); letter-spacing: -.07em;
color: rgba(245, 244, 255, .045); /* filled, not outlined, for the same reason as .hero__ghost */
pointer-events: none; user-select: none;
}
.story__phones { position: relative; z-index: 1; height: min(84vh, 880px); height: min(84svh, 880px); aspect-ratio: 414 / 868; }
.story__phones .phone-host, .story__phones .xhost { position: absolute; inset: 0; height: auto; }
.story__phones .xhost { visibility: hidden; }
/* Chapter 8 is two screens long; its text stays put while the phone comes apart. */
.step.step--long { min-height: 300vh; min-height: 300svh; align-items: flex-start; }
.step.step--long .step__card { position: sticky; top: max(96px, calc(50vh - 170px)); }
.story__rail { position: absolute; right: 0; top: 50%; translate: 0 -50%; display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
.story__rail li { width: 12px; height: 34px; border: var(--b) solid var(--fg); transition: background .3s, height .3s; }
.story__rail li.done { background: var(--fg); }
.story__rail li.on { background: var(--pink); height: 54px; }
.story__steps { grid-column: 1; grid-row: 1; padding-bottom: 50vh; padding-bottom: 50svh; }
.step { min-height: 100vh; min-height: 100svh; display: flex; align-items: center; padding-right: 4vw; }
.step__card { opacity: .16; transition: opacity .5s ease; }
.step.is-active .step__card { opacity: 1; }
.step__no { margin: 0; font: 700 14px/1 var(--mono); letter-spacing: .1em; color: var(--pink); }
.step__title { margin: 18px 0 22px; font: 900 clamp(34px, 4.3vw, 74px)/.94 var(--display); letter-spacing: -.04em; text-transform: uppercase; }
.step__text { margin: 0; max-width: 30em; font-size: clamp(18px, 1.35vw, 21px); color: #dcdaf3; }
.step__gesture { display: inline-flex; gap: 12px; margin: 30px 0 0; padding: 10px 14px; border: var(--b) solid var(--fg); font: 700 12.5px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.step__gesture span { color: var(--cyan); }
/* ---------- details ---------- */
.cards {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
max-width: var(--max); margin: 0 auto; padding: 0 var(--pad);
gap: 0;
}
.card {
position: relative;
min-height: 380px;
padding: 28px 28px 32px;
border: var(--b) solid var(--fg);
margin: 0 -2px -2px 0; /* collapse the borders into a single grid */
display: flex; flex-direction: column;
transition: transform .2s ease, box-shadow .2s ease, z-index 0s .2s;
}
.card:hover { transform: translate(-6px, -6px); box-shadow: 12px 12px 0 var(--fg); z-index: 2; transition: transform .2s ease, box-shadow .2s ease; }
.card h3 { margin: auto 0 12px; font: 900 clamp(24px, 2.2vw, 34px)/1 var(--display); letter-spacing: -.03em; text-transform: uppercase; }
.card p { margin: 0; font-size: 17px; line-height: 1.5; }
.card__no { position: absolute; top: 22px; right: 24px; font: 800 14px var(--mono); }
.card--ink { background: var(--bg-2); }
.card--ink p { color: var(--dim); }
.card--cyan { background: var(--cyan); color: var(--ink); }
.card--violet { background: var(--violet); }
.card--pink { background: var(--pink); color: var(--ink); }
.card__art { height: 150px; margin-bottom: 28px; position: relative; }
/* card art: alphabet that hops from edge to edge */
.art-flip__phone { position: absolute; left: 0; top: 0; width: 120px; height: 150px; border: var(--b) solid currentColor; border-radius: 18px; }
.art-flip__phone b { position: absolute; top: 12px; right: 8px; width: 12px; font: 700 10px/1.25 var(--ui); text-align: center; word-break: break-all; color: var(--cyan); transition: right .55s cubic-bezier(.7, 0, .3, 1); }
.art-flip.is-left .art-flip__phone b { right: 96px; }
/* compact and comfy rows */
.art-rows { display: flex; gap: 22px; }
.art-rows__col { display: flex; flex-direction: column; gap: 9px; width: 120px; padding: 12px; border: var(--b) solid var(--ink); border-radius: 14px; }
.art-rows__col b { display: block; height: 12px; background: var(--ink); border-radius: 3px; }
.art-rows__col--comfy { gap: 16px; }
.art-rows__col--comfy b { height: 20px; }
/* update bar */
.art-update { display: flex; flex-direction: column; justify-content: center; gap: 16px; }
.art-update__v { font: 900 46px/1 var(--display); letter-spacing: -.04em; }
.art-update__bar { display: block; height: 22px; border: var(--b) solid var(--fg); }
.art-update__bar b { display: block; height: 100%; background: var(--fg); animation: fillbar 3.2s cubic-bezier(.6, 0, .2, 1) infinite; }
@keyframes fillbar { 0% { width: 0; } 70%, 100% { width: 100%; } }
/* move in */
.art-move { display: flex; align-items: center; gap: 18px; }
.art-move__box { display: grid; place-items: center; width: 92px; height: 92px; border: var(--b) solid var(--ink); background: var(--fg); color: var(--ink); font: 900 26px var(--display); font-style: normal; box-shadow: 5px 5px 0 var(--ink); }
.art-move__box--f { background: var(--ink); }
.art-move__box--f img { width: 60px; height: 60px; }
.art-move__arrow { width: 44px; height: 44px; stroke-width: 3; animation: nudge 1.2s ease-in-out infinite; }
@keyframes nudge { 50% { translate: 8px 0; } }
/* icon shapes to pick from */
.art-shapes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.art-shapes button { all: unset; cursor: pointer; padding: 5px; border: var(--b) solid transparent; transition: border-color .2s, transform .15s; }
.art-shapes button:hover { transform: translateY(-3px); }
.art-shapes button:focus-visible { outline: 3px solid var(--cyan); outline-offset: 2px; }
.art-shapes button[aria-pressed="true"] { border-color: var(--cyan); }
.art-shapes i { display: block; width: 60px; height: 60px; background: var(--grad); border: var(--b) solid var(--fg); }
.art-shapes [data-shape="squircle"] i { border-radius: 30%; }
.art-shapes [data-shape="circle"] i { border-radius: 50%; }
.art-shapes [data-shape="square"] i { border-radius: 14%; }
.art-shapes [data-shape="teardrop"] i { border-radius: 50% 50% 50% 14%; }
/* switches on the cards */
.cards__note { max-width: var(--max); margin: 0 auto 22px; padding: 0 var(--pad); font: 700 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--pink); }
.card__try, .card__seg button {
font: 700 12.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
padding: 12px 15px; border: var(--b) solid currentColor; background: transparent; color: inherit; cursor: pointer;
transition: background .2s, color .2s, transform .14s, box-shadow .14s, translate .3s cubic-bezier(.2, .8, .2, 1);
}
.card__try { align-self: flex-start; margin-top: 24px; box-shadow: 4px 4px 0 var(--cyan); }
.card__try:active { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--cyan); }
.card__try[aria-pressed="true"] { background: var(--cyan); color: var(--ink); border-color: var(--cyan); }
.card__seg { display: inline-flex; align-self: flex-start; margin-top: 24px; }
.card__seg button + button { border-left: 0; }
.card__seg button[aria-pressed="true"] { background: var(--ink); color: var(--cyan); border-color: var(--ink); }
/* double tap */
.art-tap { display: grid; place-items: center; }
.art-tap i { position: absolute; width: 56px; height: 56px; border-radius: 50%; border: 3px solid var(--ink); animation: tap 1.8s ease-out infinite; }
.art-tap i:nth-child(2) { animation-delay: .22s; }
@keyframes tap { 0% { scale: .3; opacity: 1; } 60%, 100% { scale: 2.3; opacity: 0; } }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); max-width: var(--max); margin: 0 auto; padding: clamp(60px, 8vw, 110px) var(--pad); }
.stat { padding: 0 20px; border-left: var(--b) solid var(--fg); }
.stat b { display: block; font: 900 clamp(70px, 10vw, 170px)/.9 var(--display); letter-spacing: -.06em; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.stat span { display: block; margin-top: 14px; font: 700 13px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
/* ---------- privacy ---------- */
.privacy {
background: var(--cyan);
color: var(--ink);
padding: clamp(80px, 11vw, 150px) var(--pad);
}
.privacy > * { max-width: var(--max); margin-left: auto; margin-right: auto; }
.privacy__title { margin-top: 22px; margin-bottom: clamp(40px, 6vw, 80px); font: 900 clamp(40px, 6.8vw, 112px)/.92 var(--display); letter-spacing: -.045em; text-transform: uppercase; }
.privacy__hl { display: inline-block; padding: 0 .12em; background: var(--ink); color: var(--cyan); transform: rotate(-2deg); }
.privacy__list { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
.pitem { position: relative; padding: 34px 34px 40px 0; border-top: 3px solid var(--ink); }
.pitem + .pitem { padding-left: 34px; border-left: 3px solid var(--ink); }
.pitem__no { display: grid; place-items: center; width: 54px; height: 54px; border-radius: 50%; background: var(--ink); color: var(--cyan); font: 900 24px var(--display); }
.pitem h3 { margin: 22px 0 10px; font: 900 clamp(24px, 2.4vw, 36px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
.pitem p { margin: 0; max-width: 28em; font-size: clamp(17px, 1.35vw, 20px); }
.privacy__end { margin-top: clamp(40px, 6vw, 80px); padding-top: 34px; border-top: 3px solid var(--ink); font: 900 clamp(26px, 3.4vw, 54px)/1.02 var(--display); letter-spacing: -.035em; text-transform: uppercase; }
.privacy__note { margin-top: 26px; font: 600 14px/1.5 var(--mono); }
/* ---------- playground ---------- */
.play {
background:
linear-gradient(to right, rgba(245, 244, 255, .05) 1px, transparent 1px) 0 0 / 48px 48px,
linear-gradient(to bottom, rgba(245, 244, 255, .05) 1px, transparent 1px) 0 0 / 48px 48px,
var(--bg);
}
.play__grid {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: clamp(30px, 5vw, 90px);
align-items: center;
max-width: var(--max); margin: 0 auto;
padding: 0 var(--pad) clamp(80px, 10vw, 140px);
}
.play__stage { display: grid; place-items: center; position: relative; }
.play__stage::before {
content: ""; position: absolute; width: 70%; aspect-ratio: 1; border-radius: 50%;
background: radial-gradient(closest-side, rgba(101, 61, 240, .55), rgba(252, 89, 251, .18) 60%, transparent);
filter: blur(30px);
}
.play .phone-host { height: min(82vh, 860px); height: min(82svh, 860px); position: relative; touch-action: none; }
.play__caption {
margin: 0 0 28px; min-height: 3.2em;
font: 800 clamp(22px, 2.2vw, 34px)/1.15 var(--display); letter-spacing: -.03em; text-transform: uppercase;
}
.chips { display: flex; flex-wrap: wrap; gap: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 9px;
padding: 12px 16px;
font: 700 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
color: var(--fg); background: transparent;
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
cursor: pointer; transition: transform .12s, box-shadow .12s, background .2s, color .2s;
}
.chip .gly { width: 18px; height: 18px; }
.chip:hover { background: var(--fg); color: var(--ink); }
.chip:active { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--violet); }
.chip.is-on { background: var(--cyan); color: var(--ink); }
.chip--ghost { box-shadow: none; border-style: dashed; }
.howto { margin: 36px 0 0; padding: 0; list-style: none; border-top: var(--b) solid var(--fg); }
.howto li { padding: 13px 0; border-bottom: 1px solid rgba(245, 244, 255, .2); font-size: 17px; color: var(--dim); }
.howto b { color: var(--fg); font-weight: 700; }
/* ---------- get it ---------- */
.get { background: var(--violet); padding: clamp(80px, 11vw, 150px) var(--pad); }
.get > * { max-width: var(--max); margin-left: auto; margin-right: auto; }
.get .tag { color: var(--fg); }
.get__title { margin-top: 10px; margin-bottom: clamp(30px, 5vw, 70px); font: 900 clamp(44px, 14.6vw, 260px)/.82 var(--display); letter-spacing: -.07em; text-transform: uppercase; color: var(--fg); text-shadow: .045em .045em 0 var(--ink); }
.get__grid { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: clamp(30px, 5vw, 90px); align-items: start; }
.qr {
margin: 0; padding: 22px; width: clamp(220px, 22vw, 300px);
background: var(--fg); color: var(--ink);
border: var(--b) solid var(--ink); box-shadow: 12px 12px 0 var(--ink);
transform: rotate(-3deg);
}
.qr__code { width: 100%; height: auto; }
.qr__code path { fill: none; stroke-width: 1; shape-rendering: crispEdges; }
.qr figcaption { margin-top: 14px; font: 700 12.5px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .08em; text-align: center; }
.get__main { display: flex; flex-direction: column; align-items: flex-start; }
.get .btn--huge { --c: var(--pink); }
.steps { counter-reset: s; margin: 40px 0 34px; padding: 0; list-style: none; max-width: 40em; }
.steps li { counter-increment: s; position: relative; padding: 16px 0 16px 64px; border-top: var(--b) solid var(--ink); font-size: clamp(17px, 1.3vw, 20px); }
.steps li:last-child { border-bottom: var(--b) solid var(--ink); }
.steps li::before { content: "0" counter(s); position: absolute; left: 0; top: 16px; font: 900 22px/1 var(--display); color: var(--ice); }
.get .textlink { color: var(--fg); }
.faq { margin-top: clamp(60px, 8vw, 110px); border-top: 3px solid var(--ink); }
.faq details { border-bottom: 3px solid var(--ink); }
.faq summary {
list-style: none; cursor: pointer; position: relative;
padding: 24px 60px 24px 0;
font: 800 clamp(18px, 1.8vw, 28px)/1.15 var(--display); letter-spacing: -.02em; text-transform: uppercase;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
content: ""; position: absolute; right: 6px; top: 50%; width: 26px; height: 26px; margin-top: -13px;
background: linear-gradient(var(--fg), var(--fg)) center / 100% 4px no-repeat, linear-gradient(var(--fg), var(--fg)) center / 4px 100% no-repeat;
transition: rotate .25s;
}
.faq details[open] summary::after { rotate: 45deg; }
.faq details p { margin: 0 0 26px; max-width: 46em; font-size: clamp(17px, 1.3vw, 19px); }
/* ---------- footer ---------- */
.foot { padding: 40px var(--pad) 34px; overflow: hidden; }
.foot__word {
font: 900 16.6vw/.8 var(--display); letter-spacing: -.07em; text-transform: uppercase;
background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent;
margin: 0 0 30px -.03em; white-space: nowrap;
}
.foot__row { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 40px; padding-top: 22px; border-top: var(--b) solid var(--fg); font: 500 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
.foot__row p { margin: 0; }
.foot__row .textlink { margin-left: auto; color: var(--fg); }
/* =====================================================================
The phone. Drawn at 390 x 844 (a phone's CSS pixels) and scaled to fit
its host, so every measurement inside stays the same at any size.
===================================================================== */
.phone-host { position: relative; aspect-ratio: 414 / 868; }
.phone {
position: absolute; left: 0; top: 0;
width: 414px; height: 868px;
padding: 12px;
border-radius: 60px;
background: linear-gradient(150deg, #34324d, #121122 45%, #08070f);
box-shadow:
inset 0 0 0 2px #4a4767,
inset 0 0 0 5px #0a0914,
0 50px 90px -30px rgba(101, 61, 240, .7),
0 0 0 2px #000;
transform-origin: 0 0;
transform: scale(var(--s, .5));
user-select: none; -webkit-user-select: none;
}
.phone::before, .phone::after { /* side buttons */
content: ""; position: absolute; right: -4px; width: 4px; border-radius: 0 3px 3px 0; background: #2b2940;
}
.phone::before { top: 190px; height: 70px; }
.phone::after { top: 290px; height: 110px; }
.screen {
position: relative;
width: 390px; height: 844px;
border-radius: 48px;
overflow: hidden;
background: #05061a;
font-family: var(--ui);
color: #fff;
isolation: isolate;
contain: strict;
}
.screen * { box-sizing: border-box; }
.screen .ic svg, .screen .gl { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* wallpaper and home screen sit in one layer, so the folder blur takes both */
.world { position: absolute; inset: 0; transform-origin: 50% 50%; }
.wall {
position: absolute; inset: -30px;
background:
radial-gradient(70% 38% at 8% 12%, rgba(29, 219, 253, .62), transparent 70%),
radial-gradient(60% 34% at 95% 92%, rgba(252, 89, 251, .55), transparent 70%),
radial-gradient(55% 30% at 78% 40%, rgba(101, 61, 240, .7), transparent 72%),
linear-gradient(168deg, #2a1fb8 0%, #120d82 30%, #0a0954 58%, #050525 100%);
}
.wall::before, .wall::after { /* the icon's sweeping light lines */
content: ""; position: absolute; border-radius: 50%;
border-top: 3px solid rgba(118, 250, 252, .55);
filter: blur(1.5px);
}
.wall::before { left: -40%; top: 18%; width: 150%; height: 70%; transform: rotate(-22deg); }
.wall::after { left: -10%; top: 52%; width: 150%; height: 60%; transform: rotate(-24deg); border-top-color: rgba(252, 89, 251, .5); }
.sbar { position: absolute; left: 0; right: 0; top: 0; height: 40px; padding: 14px 28px 0; display: flex; justify-content: space-between; font: 600 14px/1 var(--ui); z-index: 30; pointer-events: none; }
.sbar__icons { display: flex; align-items: center; gap: 6px; }
.sbar__sig { display: flex; align-items: flex-end; gap: 2px; height: 11px; }
.sbar__sig i { width: 3px; background: #fff; border-radius: 1px; }
.sbar__sig i:nth-child(1) { height: 4px; } .sbar__sig i:nth-child(2) { height: 6px; } .sbar__sig i:nth-child(3) { height: 8px; } .sbar__sig i:nth-child(4) { height: 11px; }
.sbar__bat { width: 22px; height: 11px; border-radius: 3px; border: 1.5px solid #fff; padding: 1.5px; }
.sbar__bat::before { content: ""; display: block; width: 72%; height: 100%; background: #fff; border-radius: 1px; }
.notch { position: absolute; left: 50%; top: 12px; width: 22px; height: 22px; margin-left: -11px; border-radius: 50%; background: #000; z-index: 31; box-shadow: inset 0 0 0 4px #111; }
.home { position: absolute; inset: 0; transform-origin: 50% 60%; }
.pages { position: absolute; inset: 0; }
.page { position: absolute; top: 0; width: 390px; height: 844px; }
.cell { position: absolute; display: flex; flex-direction: column; align-items: center; padding-top: 8px; }
.cell .lbl { margin-top: 7px; font: 500 12.5px/1.2 var(--ui); text-shadow: 0 1px 4px rgba(0, 0, 0, .55); white-space: nowrap; }
.cell.tap .ic, .cell.tap .fic { animation: tapb .3s ease; }
@keyframes tapb { 40% { transform: scale(.86); } }
.ic {
position: relative; display: grid; place-items: center;
width: 58px; height: 58px; border-radius: var(--shape, 30%);
transition: border-radius .35s ease;
color: #fff;
background: linear-gradient(145deg, var(--a), var(--b));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), 0 6px 14px -6px rgba(0, 0, 0, .55);
flex: none;
}
.ic svg { width: 46%; height: 46%; }
.ndot { position: absolute; top: 4px; left: calc(50% + 20px); width: 13px; height: 13px; border-radius: 50%; background: var(--pink); border: 2px solid #0d0b40; }
.fic { width: 58px; height: 58px; padding: 7px; border-radius: var(--shape, 30%); transition: border-radius .35s ease; background: rgba(236, 233, 249, .94); display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.ic--mini { width: 100%; height: auto; aspect-ratio: 1; box-shadow: none; }
.ic--mini svg { stroke-width: 2.6; }
.w-clock { position: absolute; padding: 26px 18px 0; }
.clk__line { white-space: nowrap; }
.clk__line > span { display: inline-block; will-change: transform; }
.clk__time { font: 300 88px/1 var(--ui); letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.clk__date { margin-top: 8px; font: 500 18px/1.2 var(--ui); }
.clk__wx { margin-top: 4px; font: 400 16px/1.2 var(--ui); opacity: .92; }
.w-pics { position: absolute; padding: 8px 12px 18px; }
.pics { position: relative; width: 100%; height: 100%; border-radius: var(--shape-w, 30px); transition: border-radius .35s ease; overflow: hidden; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .6); }
.pic { position: absolute; inset: 0; opacity: 0; transition: opacity 1.1s ease; }
.pic.on { opacity: 1; }
.pic--a { /* sunset over water */
background:
radial-gradient(circle at 50% 58%, #fff1b8 0 9%, #ffc56b 10%, transparent 30%),
linear-gradient(to bottom, transparent 62%, rgba(10, 6, 60, .9) 62%),
repeating-linear-gradient(to bottom, transparent 0 7px, rgba(255, 190, 120, .28) 7px 9px) 0 62% / 100% 38% no-repeat,
linear-gradient(to bottom, #3b1c8f, #c2399b 40%, #ff7a59 62%);
}
.pic--b { /* mountains */
background:
linear-gradient(150deg, transparent 52%, #1a1660 52.4%) 0 100% / 60% 62% no-repeat,
linear-gradient(-140deg, transparent 50%, #2a2390 50.4%) 100% 100% / 75% 75% no-repeat,
linear-gradient(140deg, transparent 50%, #3c32b8 50.4%) 0 100% / 75% 55% no-repeat,
radial-gradient(circle at 72% 28%, #fff 0 6%, transparent 7%),
linear-gradient(to bottom, #1ddbfd, #417df4 55%, #9a7dff);
}
.pic--c { /* neon city */
background:
linear-gradient(to top, #fc59fb 0 2px, transparent 2px) 0 100% / 100% 100% no-repeat,
linear-gradient(#0a0920, #0a0920) 8% 100% / 16% 58% no-repeat,
linear-gradient(#120f33, #120f33) 28% 100% / 18% 76% no-repeat,
linear-gradient(#0a0920, #0a0920) 52% 100% / 14% 48% no-repeat,
linear-gradient(#17134a, #17134a) 74% 100% / 20% 68% no-repeat,
linear-gradient(#0a0920, #0a0920) 98% 100% / 12% 40% no-repeat,
radial-gradient(circle at 70% 22%, #fdf6ff 0 7%, transparent 8%),
linear-gradient(to bottom, #3a0d6e, #b8279b 70%, #fc59fb);
}
.pagedots { position: absolute; left: 0; right: 0; top: 748px; display: flex; justify-content: center; gap: 8px; }
.pagedots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(255, 255, 255, .45); transition: background .2s, width .2s; }
.pagedots i.on { background: #fff; width: 18px; border-radius: 4px; }
.fscrim { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; z-index: 10; }
.fcard {
position: absolute; left: 45px; top: 282px; width: 300px;
padding: 22px 12px 14px;
border-radius: 32px;
background: #eeebf8; color: #1c1a2d;
z-index: 11; visibility: hidden; opacity: 0;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, .6);
}
.fcard__grid { display: grid; grid-template-columns: repeat(3, 1fr); row-gap: 16px; }
.fcard__app { display: flex; flex-direction: column; align-items: center; gap: 7px; font: 500 12.5px/1.2 var(--ui); }
.fcard__title { margin-top: 16px; text-align: center; font: 600 17px/1 var(--ui); }
.drawer { position: absolute; inset: 0; background: rgba(5, 4, 20, .8); z-index: 12; opacity: 0; visibility: hidden; }
.drawer__in { position: absolute; inset: 0; -webkit-mask-image: linear-gradient(to bottom, transparent 30px, #000 52px); mask-image: linear-gradient(to bottom, transparent 30px, #000 52px); } /* the list fades out under the status bar */
.list { position: absolute; left: 0; right: 0; top: 46px; padding: 0 58px 0 20px; will-change: transform; }
.search { display: flex; align-items: center; gap: 12px; height: 50px; margin: 6px 0 12px; padding: 0 18px; border-radius: 25px; background: #f2f0fb; color: #4b4867; font: 500 16px/1 var(--ui); }
.search .gl { width: 22px; height: 22px; color: #4b4867; stroke-width: 2.4; }
.hdr { height: 34px; padding: 12px 0 0 2px; font: 500 13px/1 var(--ui); color: #b9b6dc; }
.row { display: flex; align-items: center; gap: 15px; height: 54px; overflow: hidden; transition: background .2s; border-radius: 14px; }
.row--note { height: 64px; }
.row .ic { width: 40px; height: 40px; }
.row__t { display: flex; flex-direction: column; min-width: 0; }
.row__n { font: 500 17.5px/1.15 var(--ui); }
.row__p { margin-top: 3px; font: 400 13px/1.2 var(--ui); color: #cfcbef; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.flash { background: rgba(255, 255, 255, .12); }
/* Settings from the detail cards. */
.is-comfy .row { height: 66px; }
.is-comfy .row--note { height: 78px; }
.is-comfy .row .ic { width: 48px; height: 48px; }
.is-comfy .row__n { font-size: 19.5px; }
.is-lefty .list { padding: 0 20px 0 58px; }
.is-lefty .alpha { right: auto; left: 8px; }
.row.held { background: rgba(255, 255, 255, .1); }
.alpha { position: absolute; right: 8px; bottom: 112px; width: 30px; display: flex; flex-direction: column; align-items: center; z-index: 2; }
.alpha__s { width: 15px; height: 15px; margin-bottom: 8px; color: #fff; opacity: .9; }
.alpha__s svg { display: block; width: 15px; height: 15px; pointer-events: none; } /* unsized, an svg is 300 px wide and would cover the list */
.alpha__l { display: block; width: 30px; height: 21px; font: 600 12.5px/21px var(--ui); text-align: center; color: rgba(255, 255, 255, .86); transform-origin: 50% 50%; }
.alpha__l.on { color: var(--pink); font-weight: 800; }
.bubble { position: absolute; left: 0; top: 0; width: 66px; height: 66px; display: grid; place-items: center; border-radius: 50%; background: var(--grad); color: #fff; font: 700 32px/1 var(--ui); box-shadow: 0 10px 30px -6px rgba(252, 89, 251, .6); opacity: 0; z-index: 3; pointer-events: none; text-shadow: 0 1px 6px rgba(0, 0, 0, .3); }
.menu { position: absolute; left: 0; top: 0; width: 236px; padding: 6px 0; border-radius: 24px; background: #f6f4fd; color: #1e1c2c; z-index: 20; opacity: 0; visibility: hidden; transform-origin: 30% 0; box-shadow: 0 24px 50px -16px rgba(0, 0, 0, .65); }
.mi { display: flex; align-items: center; gap: 16px; height: 50px; padding: 0 20px; font: 500 16px/1 var(--ui); }
.mi .gl { width: 22px; height: 22px; stroke-width: 1.9; color: #3a3752; }
.mi--hide.pick { background: #e2dcff; }
.snack { position: absolute; left: 24px; right: 24px; bottom: 64px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-radius: 18px; background: #f1eefb; color: #1b1a29; font: 500 15px/1.2 var(--ui); z-index: 21; opacity: 0; visibility: hidden; box-shadow: 0 18px 40px -14px rgba(0, 0, 0, .6); }
.snack__undo { all: unset; font: 700 15px/1 var(--ui); color: #5a33e6; cursor: pointer; padding: 4px; }
.sheet { position: absolute; left: 0; right: 0; bottom: 0; padding: 12px 22px 36px; border-radius: 30px 30px 0 0; background: #f4f2fb; color: #1c1a2d; z-index: 22; transform: translateY(105%); box-shadow: 0 -20px 50px -20px rgba(0, 0, 0, .5); }
.sheet__grab { display: block; width: 36px; height: 4px; margin: 0 auto 14px; border-radius: 2px; background: #a9a5c2; }
.sheet h4 { margin: 0 0 10px; font: 600 22px/1.2 var(--ui); }
.sheet__h { margin: 14px 0 8px; font: 600 13.5px/1 var(--ui); color: #5d5a78; }
.seg { display: flex; border: 1.5px solid #8a86a6; border-radius: 22px; overflow: hidden; }
.seg span { flex: 1; display: grid; place-items: center; height: 40px; font: 500 14px/1 var(--ui); border-left: 1.5px solid #8a86a6; transition: background .2s; }
.seg span:first-child { border-left: 0; }
.seg span.on { background: #ddd5ff; font-weight: 700; }
.cam { position: absolute; inset: 0; background: #000; z-index: 25; visibility: hidden; transform: translateX(-100%); }
.cam__view {
position: absolute; left: 0; right: 0; top: 52px; height: 560px; overflow: hidden;
background:
radial-gradient(circle at 66% 34%, #fff6d8 0 5%, #ffd48a 6%, transparent 22%),
linear-gradient(to bottom, #4b2ac9, #c2399b 45%, #ff9b6a 70%, #ffd48a);
}
.cam__hill { position: absolute; left: -10%; right: -10%; bottom: 0; }
.cam__hill--1 { height: 46%; background: #2a1563; clip-path: polygon(0 40%, 18% 22%, 36% 46%, 55% 18%, 74% 40%, 90% 28%, 100% 36%, 100% 100%, 0 100%); }
.cam__hill--2 { height: 30%; background: #120a36; clip-path: polygon(0 30%, 25% 12%, 48% 40%, 70% 20%, 100% 44%, 100% 100%, 0 100%); }
.cam__grid { position: absolute; inset: 0; background: linear-gradient(to right, transparent 33%, rgba(255, 255, 255, .35) 33%, rgba(255, 255, 255, .35) calc(33% + 1px), transparent calc(33% + 1px), transparent 66%, rgba(255, 255, 255, .35) 66%, rgba(255, 255, 255, .35) calc(66% + 1px), transparent calc(66% + 1px)), linear-gradient(to bottom, transparent 33%, rgba(255, 255, 255, .35) 33%, rgba(255, 255, 255, .35) calc(33% + 1px), transparent calc(33% + 1px), transparent 66%, rgba(255, 255, 255, .35) 66%, rgba(255, 255, 255, .35) calc(66% + 1px), transparent calc(66% + 1px)); }
.cam__focus { position: absolute; left: 50%; top: 50%; width: 76px; height: 76px; margin: -38px; border: 2px solid #ffe066; border-radius: 6px; }
.cam__modes { position: absolute; left: 0; right: 0; top: 632px; display: flex; justify-content: center; gap: 26px; font: 600 13px/1 var(--ui); letter-spacing: .08em; text-transform: uppercase; color: #9d9ab8; }
.cam__modes .on { color: var(--ice); }
.cam__bar { position: absolute; left: 0; right: 0; top: 684px; display: flex; align-items: center; justify-content: space-between; padding: 0 44px; }
.cam__thumb { width: 50px; height: 50px; border-radius: 12px; background: linear-gradient(145deg, #ff9b6a, #4b2ac9); border: 2px solid #fff; }
.cam__shutter { width: 78px; height: 78px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 5px #000, 0 0 0 9px #fff; transition: transform .12s ease; }
.cam__flip { display: grid; place-items: center; width: 50px; height: 50px; border-radius: 50%; background: #26243a; }
.cam__flip .gl { width: 24px; height: 24px; color: #fff; }
.cam__close { position: absolute; top: 58px; right: 16px; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .45); color: #fff; font: 400 26px/1 var(--ui); cursor: pointer; }
.gbar { position: absolute; left: 50%; bottom: 9px; width: 124px; height: 5px; margin-left: -62px; border-radius: 3px; background: rgba(255, 255, 255, .88); z-index: 40; pointer-events: none; }
.finger {
position: absolute; left: 0; top: 0; width: 50px; height: 50px; margin: -25px 0 0 -25px;
border-radius: 50%;
background: rgba(255, 255, 255, .26);
border: 2px solid rgba(255, 255, 255, .85);
box-shadow: 0 0 0 7px rgba(255, 255, 255, .1), 0 10px 26px rgba(0, 0, 0, .35);
z-index: 50; opacity: 0; pointer-events: none;
}
.finger__ring { position: absolute; inset: -9px; border-radius: 50%; background: conic-gradient(var(--pink) calc(var(--ring, 0) * 1turn), transparent 0); -webkit-mask: radial-gradient(closest-side, transparent 78%, #000 80%); mask: radial-gradient(closest-side, transparent 78%, #000 80%); }
/* ---------- the page's wave alphabet ---------- */
.pagewave {
position: fixed; right: 8px; top: 50%; translate: 0 -50%; z-index: 85;
display: flex; flex-direction: column; align-items: center;
padding: 10px 6px; touch-action: none; user-select: none; -webkit-user-select: none;
}
.pagewave a {
display: grid; place-items: center; width: 26px; height: 27px;
font: 800 12px/1 var(--display); color: rgba(255, 255, 255, .62); text-decoration: none;
transform-origin: 100% 50%; transition: color .2s;
mix-blend-mode: difference; /* readable on the dark, cyan and violet sections alike */
}
.pagewave a:hover, .pagewave a:focus-visible { color: #fff; }
.pagewave a.on { color: #fff; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; }
.pagewave__bubble {
position: absolute; right: 66px; top: 0;
display: flex; align-items: center; gap: 10px; padding: 6px 14px 6px 6px;
background: var(--fg); color: var(--ink); border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--pink);
opacity: 0; pointer-events: none; white-space: nowrap; transition: opacity .2s;
}
.pagewave__bubble b { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; background: var(--grad); color: #fff; font: 900 20px/1 var(--display); }
.pagewave__bubble i { font: 800 12.5px/1 var(--mono); font-style: normal; text-transform: uppercase; letter-spacing: .08em; }
.pagewave.is-active .pagewave__bubble { opacity: 1; }
/* ---------- toast, confetti, finger cursor ---------- */
.toast {
position: fixed; left: 50%; bottom: 22px; z-index: 120; max-width: calc(100vw - 32px);
transform: translate(-50%, 160%);
padding: 13px 16px; background: var(--cyan); color: var(--ink);
border: var(--b) solid var(--ink); box-shadow: 5px 5px 0 var(--ink);
font: 700 13px/1.35 var(--mono); text-transform: uppercase; letter-spacing: .05em;
transition: transform .4s cubic-bezier(.2, .8, .2, 1);
}
.toast.is-on { transform: translate(-50%, 0); }
.confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 150; }
.tcursor {
position: fixed; left: 0; top: 0; z-index: 140; width: 46px; height: 46px; margin: -23px 0 0 -23px;
border-radius: 50%; background: rgba(255, 255, 255, .2); border: 2px solid rgba(255, 255, 255, .9);
box-shadow: 0 0 0 6px rgba(255, 255, 255, .08), 0 10px 26px rgba(0, 0, 0, .35);
pointer-events: none; opacity: 0; transition: opacity .2s, scale .15s;
}
.over-phone .tcursor { opacity: 1; }
.tcursor.is-down { scale: .82; }
.over-phone .tcursor.is-down { opacity: 0; } /* the phone draws its own finger while pressed */
.over-phone .phone-host, .over-phone .phone-host * { cursor: none !important; }
.btn, .chip { transition: transform .14s ease, box-shadow .14s ease, background .2s, color .2s, translate .35s cubic-bezier(.2, .8, .2, 1); }
/* ---------- chapter 8: the phone pulled apart ---------- */
.xhost { perspective: 2600px; }
.xphone { position: absolute; left: 0; top: 0; width: 414px; height: 868px; transform-origin: 0 0; transform-style: preserve-3d; --e: 0; }
.xl { position: absolute; left: 12px; top: 12px; width: 390px; height: 844px; border-radius: 48px; overflow: hidden; font-family: var(--ui); color: #fff; box-shadow: 0 0 0 2px rgba(118, 250, 252, calc(var(--e) * .55)); }
.xl * { box-sizing: border-box; }
.xl .ic svg, .xl .gl { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.xl--body::before, .xl--body::after { content: ""; position: absolute; right: -4px; width: 4px; border-radius: 0 3px 3px 0; background: #2b2940; }
.xl--body::before { top: 190px; height: 70px; }
.xl--body::after { top: 290px; height: 110px; }
.xl--body { overflow: visible; left: 0; top: 0; width: 414px; height: 868px; border-radius: 60px; background: linear-gradient(150deg, #34324d, #121122 45%, #08070f); box-shadow: inset 0 0 0 2px #4a4767, inset 0 0 0 5px #0a0914, 0 50px 90px -30px rgba(101, 61, 240, .7), 0 0 0 2px #000; }
.xl--list { background: rgba(5, 4, 20, calc(.35 + var(--e) * .3)); }
.xl--list .list { top: 46px; }
.xl--list .alpha { bottom: 112px; }
.xl--glass::before { content: ""; position: absolute; inset: 0; opacity: var(--e); background: linear-gradient(125deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .07) 68%, rgba(255, 255, 255, 0)); }
.xl__pin { position: absolute; right: 14px; bottom: 14px; width: 1px; height: 1px; }
.anatomy__lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: 0; }
.anatomy__lines path { fill: none; stroke: var(--cyan); stroke-width: 2; }
.anatomy__lines circle { fill: var(--bg); stroke: var(--cyan); stroke-width: 2.5; }
.anatomy__labels { position: absolute; inset: 0; list-style: none; margin: 0; padding: 0; pointer-events: none; } /* out of the flow, or the stage's grid would split its height and the phone sit high */
.anatomy__labels li {
position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%;
padding: 9px 12px 10px; background: rgba(7, 7, 13, .94);
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
opacity: 0; pointer-events: none; will-change: transform;
}
.anatomy__labels li { transition: border-color .25s, box-shadow .25s; }
.anatomy__labels li.is-on { border-color: var(--cyan); box-shadow: 4px 4px 0 var(--cyan); }
.anatomy__labels li.is-on b { color: var(--cyan); }
.anatomy__labels i { margin-right: 8px; font: 700 11px/1 var(--mono); font-style: normal; color: var(--cyan); }
.anatomy__labels b { font: 900 13px/1.15 var(--display); text-transform: uppercase; letter-spacing: -.01em; }
.anatomy__labels span { display: block; margin-top: 6px; font: 500 12px/1.3 var(--mono); color: var(--dim); white-space: nowrap; }
/* ---------- race ---------- */
.race__grid {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(30px, 5vw, 90px); align-items: center;
max-width: var(--max); margin: 0 auto; padding: 0 var(--pad) clamp(80px, 10vw, 140px);
}
.race__stage { position: relative; height: min(80vh, 840px); height: min(80svh, 840px); aspect-ratio: 414 / 868; margin: 0 auto; }
.race__host { position: absolute; inset: 0; transition: opacity .35s ease; touch-action: none; }
.race__stage[data-phase="between"] .race__host--grid,
.race__stage[data-phase="r2"] .race__host--grid,
.race__stage[data-phase="done"] .race__host--grid,
.race__stage[data-phase="idle"] .race__host--list,
.race__stage[data-phase="r1"] .race__host--list { opacity: 0; pointer-events: none; }
.race__overlay {
position: absolute; left: 8%; right: 8%; top: 50%; translate: 0 -50%; z-index: 3;
padding: 22px; background: var(--fg); color: var(--ink);
border: var(--b) solid var(--ink); box-shadow: 8px 8px 0 var(--pink);
transition: opacity .25s, scale .25s;
}
.race__stage[data-phase="r1"] .race__overlay, .race__stage[data-phase="r2"] .race__overlay, .race__stage[data-phase="done"] .race__overlay { opacity: 0; scale: .9; pointer-events: none; }
.race__pill {
position: absolute; left: 50%; top: -18px; translate: -50% 0; z-index: 4; margin: 0; white-space: nowrap;
padding: 9px 14px; background: var(--cyan); color: var(--ink); border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--ink);
font: 700 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
opacity: 0; transition: opacity .25s;
}
.race__pill b { font-family: var(--display); font-weight: 900; }
.race__stage[data-phase="r1"] .race__pill, .race__stage[data-phase="r2"] .race__pill, .race__stage[data-phase="between"] .race__pill { opacity: 1; }
.race__round { margin: 0; font: 900 clamp(26px, 3vw, 40px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
.race__msg { margin: 10px 0 0; font-size: 16px; line-height: 1.4; }
.race__find { margin: 0 0 24px; font: 800 clamp(26px, 2.6vw, 40px)/1.1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
.race__find b { display: inline-block; padding: 0 .2em; background: var(--cyan); color: var(--ink); transform: rotate(-2deg); }
.race__times { border-top: var(--b) solid var(--fg); margin-bottom: 24px; }
.race__time { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid rgba(245, 244, 255, .25); }
.race__time span { font: 700 13px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.race__time i { display: block; margin-top: 4px; font-style: normal; font-weight: 500; color: var(--dim); }
.race__time b { font: 900 clamp(34px, 4vw, 60px)/1 var(--display); letter-spacing: -.04em; font-variant-numeric: tabular-nums; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.race__verdict { min-height: 3em; margin: 0 0 26px; font: 800 clamp(18px, 1.7vw, 24px)/1.25 var(--display); text-transform: uppercase; letter-spacing: -.02em; }
.rgrid { position: absolute; left: 8px; right: 8px; top: 52px; display: grid; grid-template-columns: repeat(5, 1fr); row-gap: 12px; }
.rcell { all: unset; display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.rcell .ic { width: 50px; height: 50px; }
.rcell span { max-width: 70px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 10.5px/1.15 var(--ui); color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .6); }
.rcell.miss .ic, .row.miss { animation: miss .32s ease; }
.rcell.hit .ic { box-shadow: 0 0 0 4px #fff, 0 0 26px 6px rgba(118, 250, 252, .8); }
@keyframes miss { 25% { translate: -6px 0; } 75% { translate: 6px 0; } }
/* ---------- responsive ---------- */
@media (max-width: 1100px) {
.nav__links { display: none; }
.nav .btn--sm { margin-left: auto; }
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
body { font-size: 16px; }
.hero { padding-top: 104px; }
.hero__grid { grid-template-columns: minmax(0, 1fr); }
.hero__stage { margin-top: 30px; }
.hero .phone-host { width: min(300px, 70vw); }
.sticker { font-size: 12px; }
.sticker--a { left: 0; }
.sticker--b { right: 0; }
.spinner { width: 100px; height: 100px; left: 2%; }
.story__body { display: block; padding: 0; }
.story__stage { position: sticky; top: 0; place-items: start center; padding-top: 70px; overflow: hidden; } /* nothing the phones do may widen the page */
.story__phones { height: 52vh; height: 52svh; }
.step.step--long { align-items: flex-end; }
.step.step--long .step__card { position: sticky; top: auto; bottom: 4svh; }
.story__num { top: 32%; font-size: 58vw; }
.story__rail { right: 14px; top: 32%; gap: 6px; }
.story__rail li { width: 8px; height: 20px; }
.story__rail li.on { height: 32px; }
.story__steps { position: relative; z-index: 2; margin-top: -100vh; margin-top: -100svh; padding: 0 var(--pad) 50svh; }
.step { align-items: flex-end; padding: 0 0 4svh; }
.step__card { opacity: 0; background: rgba(7, 7, 13, .94); border: var(--b) solid var(--fg); box-shadow: 6px 6px 0 var(--violet); padding: 18px 18px 20px; width: 100%; }
.step__title { margin: 10px 0 10px; font-size: clamp(24px, 7vw, 34px); }
.step__text { font-size: 15.5px; line-height: 1.45; }
.step__gesture { display: none; }
.cards { grid-template-columns: minmax(0, 1fr); }
.card { min-height: 0; }
.stats { grid-template-columns: 1fr 1fr; row-gap: 40px; }
.privacy__list { grid-template-columns: 1fr; }
.pitem + .pitem { padding-left: 0; border-left: 0; }
.play__grid { grid-template-columns: minmax(0, 1fr); }
.play .phone-host { height: 72svh; }
.play__side { order: -1; }
.play__caption { min-height: 2.4em; margin-bottom: 18px; }
.howto { display: none; }
.get__grid { grid-template-columns: minmax(0, 1fr); }
.qr { justify-self: start; }
.foot__row .textlink { margin-left: 0; }
.story__rail { display: none; }
/* On phones the page alphabet shows up while scrolling, low on the edge like the launcher's. */
.pagewave { top: auto; bottom: 14svh; translate: none; right: 2px; opacity: 0; pointer-events: none; transition: opacity .3s; }
.pagewave.is-awake, .pagewave.is-active { opacity: 1; pointer-events: auto; }
.pagewave a { height: 25px; }
.pagewave__bubble { right: 58px; }
.anatomy__labels, .anatomy__lines { display: none; } /* the chapter's text names the layers */
.race__grid { grid-template-columns: minmax(0, 1fr); }
.race__side { order: -1; }
.race__stage { height: 70svh; }
.race__find { margin-bottom: 14px; }
.race__times { margin-bottom: 14px; }
.race__time { padding: 10px 0; }
.race__verdict { min-height: 0; margin-bottom: 18px; }
}
@media (max-width: 520px) {
.cta .btn { width: 100%; justify-content: space-between; }
.btn--huge { width: 100%; justify-content: space-between; font-size: 18px; }
.marquee--pink { transform: rotate(-2.5deg) scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
.line__in, .rv { transform: none !important; opacity: 1 !important; }
}
+54
View File
@@ -12,4 +12,58 @@
<!-- 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>
<!-- The clock widget that comes with the launcher: the time and the date. -->
<receiver
android:name="app.flexera.widget.ClockWidgetProvider"
android:exported="false"
android:label="@string/flexera_clock_widget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/flexera_clock_widget_info" />
</receiver>
<!-- The clock widget's options, a sheet over the home screen. -->
<activity
android:name="app.flexera.widget.ClockWidgetConfigActivity"
android:excludeFromRecents="true"
android:exported="false"
android:theme="@style/Theme.Transparent" />
<!-- 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>
+27 -10
View File
@@ -211,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..."
@@ -244,23 +251,33 @@ $release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) '
Step "6/7 Uploading the APK"
$assetName = "FlexeraLauncher-$Version.apk"
$stream = [IO.File]::OpenRead($apk)
# The APK goes up twice. Once under a name with its version, which is what a person looking at
# the release sees and what the app's updater downloads. And once under a name that never
# changes, so that <server>/<repo>/releases/download/latest/FlexeraLauncher.apk always leads to
# the newest APK: the address in the QR code on the app's About screen (FlexeraUpdater.LATEST_APK_URL).
# The versioned one goes first, because the updater takes the first .apk it finds.
$assetNames = @("FlexeraLauncher-$Version.apk", 'FlexeraLauncher.apk')
$uploaded = @()
try {
$file = New-Object System.Net.Http.StreamContent($stream)
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form = New-Object System.Net.Http.MultipartFormDataContent
$form.Add($file, 'attachment', $assetName)
$asset = Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) 'Uploading the APK'
foreach ($assetName in $assetNames) {
$stream = [IO.File]::OpenRead($apk)
try {
$file = New-Object System.Net.Http.StreamContent($stream)
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form = New-Object System.Net.Http.MultipartFormDataContent
$form.Add($file, 'attachment', $assetName)
$uploaded += Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) "Uploading $assetName"
} finally {
$stream.Dispose()
}
}
} catch {
# A release without its APK would make every installed copy report a failed check.
$client.DeleteAsync("$Api/releases/$($release.id)").Result | Out-Null
$client.DeleteAsync("$Api/tags/v$Version").Result | Out-Null
throw
} finally {
$stream.Dispose()
}
Write-Host "Uploaded: $($asset.browser_download_url)"
$uploaded | ForEach-Object { Write-Host "Uploaded: $($_.browser_download_url)" }
# --- 7. Source tag -------------------------------------------------------------------------------
@@ -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>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Hide app" in an app's long-press menu: an eye with a line through it. Drawn as strokes;
the menu tints it like its other icons. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- The eye's outline, open on the side the line crosses first. -->
<path
android:pathData="M9.9,5.2C10.6,5.07 11.3,5 12,5C16.2,5 19.7,7.6 21.5,12C20.9,13.4 20.1,14.6 19.1,15.6M16.4,17.6C15.1,18.5 13.6,19 12,19C7.8,19 4.3,16.4 2.5,12C3.3,10 4.5,8.4 6,7.2"
android:strokeColor="@android:color/white"
android:strokeWidth="1.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- What the line leaves of the pupil. -->
<path
android:pathData="M14.1,14.1C13.56,14.66 12.82,15 12,15C10.34,15 9,13.66 9,12C9,11.18 9.34,10.44 9.9,9.9"
android:strokeColor="@android:color/white"
android:strokeWidth="1.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- The line. -->
<path
android:pathData="M4,4L20,20"
android:strokeColor="@android:color/white"
android:strokeWidth="1.8"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
What Android is told the clock widget looks like before it has anything to show: a plain
clock that keeps time by itself. Flexera Launcher never shows it; it draws the widget itself
(app.flexera.widget.ClockWidgetView).
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<TextClock
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:format12Hour="h:mm"
android:format24Hour="H:mm"
android:textColor="@android:color/white"
android:textSize="48sp" />
<TextClock
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:format12Hour="EEEE, d MMMM"
android:format24Hour="EEEE, d MMMM"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
@@ -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>
+22 -4
View File
@@ -8,6 +8,12 @@
<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
@@ -15,10 +21,15 @@
<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. Close together, a
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
@@ -60,7 +71,14 @@
<dimen name="flexera_drawer_text_shadow_radius">3dp</dimen>
<dimen name="flexera_drawer_text_shadow_dy">1dp</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>
+105 -18
View File
@@ -41,8 +41,13 @@
</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">Widgets of Smart Launcher itself</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>
@@ -61,10 +66,10 @@
<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_share_qr_description">QR code that downloads the latest version</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>
<string name="flexera_about_privacy_title">What goes online</string>
<string name="flexera_about_privacy_body">Flexera Launcher goes online for two things. It asks its release page whether a newer version exists, about once a day, and downloads that version when you ask. And if you pick a place for the weather on a clock widget, it asks Open-Meteo, a weather service, for the weather there; that place is all it sends. Nothing else about you, your apps or your phone leaves it.</string>
<!-- Lawnchair's wording promised web results and grid settings that this app does not have -->
<string name="all_apps_device_search_hint">Search apps and more</string>
@@ -74,6 +79,9 @@
<!-- 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>
@@ -84,35 +92,114 @@
<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_hide_app">Hide app</string>
<!-- Shown after an app was hidden from the app list. %1$s is the app's name -->
<string name="flexera_app_hidden">%1$s is hidden</string>
<string name="flexera_folder_blur">Blur behind open folders</string>
<!-- Home screen settings, Widgets: every widget's edges meet the edges of the icons -->
<string name="flexera_align_widgets">Line up with icons</string>
<string name="flexera_align_widgets_description">The edges of every widget meet the edges of the icons above, below and beside it</string>
<string name="flexera_folder_blur_description">Blurs the home screen while a folder is open</string>
<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 clock widget that comes with the launcher (app.flexera.widget) -->
<!-- Its name in the list of widgets -->
<string name="flexera_clock_widget">Clock</string>
<string name="flexera_clock_widget_description">The time and the date, lined up with your icons</string>
<!-- The clock widget's options, reached from its edit button -->
<string name="flexera_clock_widget_alignment">Alignment</string>
<string name="flexera_clock_widget_left">Left</string>
<string name="flexera_clock_widget_center">Center</string>
<string name="flexera_clock_widget_right">Right</string>
<string name="flexera_clock_widget_size">Size</string>
<string name="flexera_clock_widget_small">Small</string>
<string name="flexera_clock_widget_medium">Medium</string>
<string name="flexera_clock_widget_large">Large</string>
<string name="flexera_clock_widget_weight">Weight</string>
<string name="flexera_clock_widget_thin">Thin</string>
<string name="flexera_clock_widget_light">Light</string>
<string name="flexera_clock_widget_regular">Regular</string>
<string name="flexera_clock_widget_bold">Bold</string>
<string name="flexera_clock_widget_weather">Weather</string>
<string name="flexera_clock_widget_weather_off">Off. Look a place up to show its weather under the date.</string>
<!-- %1$s is a place, such as "Berlin, State of Berlin, Germany" -->
<string name="flexera_clock_widget_weather_on">Showing the weather in %1$s</string>
<string name="flexera_clock_widget_weather_place">Town or city</string>
<string name="flexera_clock_widget_weather_search">Search</string>
<string name="flexera_clock_widget_weather_nothing">No place of that name was found</string>
<string name="flexera_clock_widget_weather_failed">The weather service could not be reached. Check the connection and try again.</string>
<string name="flexera_clock_widget_weather_remove">Turn weather off</string>
<string name="flexera_clock_widget_weather_source">Weather data by Open-Meteo.com. The launcher asks it about the place you pick, about once an hour while the clock is in view, and sends nothing else.</string>
<!-- The clock widget's weather line. %1$d is the temperature, %2$s a word for the sky, such as "Overcast" -->
<string name="flexera_weather_line">%1$d° · %2$s</string>
<string name="flexera_weather_clear">Clear</string>
<string name="flexera_weather_mostly_clear">Mostly clear</string>
<string name="flexera_weather_partly_cloudy">Partly cloudy</string>
<string name="flexera_weather_overcast">Overcast</string>
<string name="flexera_weather_fog">Fog</string>
<string name="flexera_weather_drizzle">Drizzle</string>
<string name="flexera_weather_rain">Rain</string>
<string name="flexera_weather_freezing_rain">Freezing rain</string>
<string name="flexera_weather_snow">Snow</string>
<string name="flexera_weather_showers">Showers</string>
<string name="flexera_weather_snow_showers">Snow showers</string>
<string name="flexera_weather_thunderstorm">Thunderstorm</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>
<!-- Button -->
<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>
<!-- Shown on the button while the check runs -->
<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_failed_title">The check for updates didn\'t work</string>
<!-- Button -->
<string name="flexera_update_try_again">Try again</string>
<!-- Button that asks Android for local network access -->
<string name="flexera_update_allow_local_network">Allow access</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_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>
<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 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 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. Once you allow it, the check runs again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then 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 try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. 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>
<string name="flexera_update_available">Version %1$s is available</string>
<!-- Button. The download is followed by the installation without another tap -->
<string name="flexera_update_download">Download and install</string>
<!-- %1$s is a file size such as 24 MB -->
<string name="flexera_update_download_size">A newer version is available (%1$s)</string>
<string name="flexera_update_download_no_size">A newer version is available</string>
<string name="flexera_update_download_failed">The download didn\'t finish. Tap to try again.</string>
<string name="flexera_update_wrong_file">The downloaded file isn\'t a newer Flexera Launcher, so it was removed. Tap to try again.</string>
<string name="flexera_update_download_size">%1$s download. The installation starts as soon as it\'s done.</string>
<string name="flexera_update_download_no_size">The installation starts as soon as the download is done.</string>
<string name="flexera_update_download_failed">The download didn\'t finish.</string>
<string name="flexera_update_wrong_file">The downloaded file isn\'t a newer Flexera Launcher, so it was removed.</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_downloading">Downloading version %1$s</string>
<!-- %1$d is a percentage from 0 to 100 -->
<string name="flexera_update_progress">%1$d%%</string>
<string name="flexera_update_progress">%1$d%%. The installation starts as soon as it\'s done.</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_install">Install version %1$s</string>
<string name="flexera_update_install_hint">Downloaded and ready. If Android asks, allow Flexera Launcher to install apps, then tap here again.</string>
<string name="flexera_update_ready">Version %1$s is ready to install</string>
<!-- Button -->
<string name="flexera_update_install">Install now</string>
<string name="flexera_update_install_hint">If Android asks, allow Flexera Launcher to install apps. The installation then carries on by itself.</string>
<string name="flexera_update_whats_new">What\'s new</string>
<string name="flexera_update_notify">Notify me about new versions</string>
<string name="flexera_update_notify_hint">Notifications are off, so a new version shows up only on this screen</string>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Flexera Launcher's clock widget (app.flexera.widget.ClockWidgetProvider). Four cells by two
when first placed, resizable from two by one upwards. 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. Nothing has to be set up when it
is added (configuration_optional); its edit button opens the options for alignment and size.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:description="@string/flexera_clock_widget_description"
android:configure="app.flexera.widget.ClockWidgetConfigActivity"
android:initialLayout="@layout/flexera_clock_widget_placeholder"
android:previewLayout="@layout/flexera_clock_widget_placeholder"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable|configuration_optional" />
@@ -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" />
@@ -9,6 +9,8 @@ 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
@@ -73,7 +75,9 @@ class SmartLauncherBackupConverter(
val folders: Int,
val appsInFolders: Int,
val widgets: Int,
/** Smart Launcher's own widgets, which stay behind. */
/** 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,
@@ -98,6 +102,9 @@ class SmartLauncherBackupConverter(
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,
@@ -115,6 +122,7 @@ class SmartLauncherBackupConverter(
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,
)
@@ -130,28 +138,9 @@ class SmartLauncherBackupConverter(
* to show them.
*/
suspend fun convertAndRestore() {
val layout = withContext(Dispatchers.IO) { withUnpackedBackup { dir -> readLayout(dir) } }
withContext(Dispatchers.IO) {
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()
}
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
@@ -161,6 +150,50 @@ class SmartLauncherBackupConverter(
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 {
@@ -177,23 +210,29 @@ class SmartLauncherBackupConverter(
File(context.cacheDir, "$TEMP_DIR_PREFIX${UUID.randomUUID()}").apply { mkdirs() }
/**
* Unpacks the database and the preference files, and nothing else, into [dir]. Entry names
* come from the file and are not trusted: only the two known places are accepted, each file
* lands under its bare name, and a file of unreasonable size ends the import.
* 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) {
@@ -227,7 +266,7 @@ class SmartLauncherBackupConverter(
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)
return readHomeScreens(db, grid, dir)
}
}
@@ -259,7 +298,7 @@ class SmartLauncherBackupConverter(
return null
}
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?): Layout {
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?, dir: File): Layout {
data class Row(
val launchable: Long?,
val widget: Long?,
@@ -311,12 +350,12 @@ class SmartLauncherBackupConverter(
row.y + row.height <= gridRows && cells.none { it in taken }
val read: HomeThing? = when {
row.widget != null -> readWidgetProvider(db, row.widget)?.let { HomeWidget(place, it) }
row.widget != null -> readWidget(db, row.widget, place, dir)
row.launchable != null -> readLaunchable(db, row.launchable, place)
else -> null
}
when {
// The only widgets without a provider this can use are Smart Launcher's own.
// The only widgets with no counterpart here are Smart Launcher's own.
read == null && row.widget != null -> ownWidgets++
read == null || !fits -> skipped++
else -> {
@@ -336,8 +375,12 @@ class SmartLauncherBackupConverter(
}
}
/** The provider of an ordinary app widget, or null for one of Smart Launcher's own. */
private fun readWidgetProvider(db: SQLiteDatabase, id: Long): String? =
/**
* 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
@@ -346,10 +389,17 @@ class SmartLauncherBackupConverter(
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)
if (type != SL_WIDGET_TYPE_APP || component == null || user != OWNER_USER) {
null
} else {
component.flattenToString()
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
}
}
}
@@ -519,6 +569,14 @@ class SmartLauncherBackupConverter(
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()
@@ -594,6 +652,21 @@ class SmartLauncherBackupConverter(
/** `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
@@ -140,6 +140,16 @@ private fun BackupContents(info: SmartLauncherBackupConverter.Info) {
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)) {
+70 -14
View File
@@ -47,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
@@ -93,10 +138,17 @@ object FlexeraDrawer {
// Other icons share the "all apps" display type (search results, for one); this tag
// tells list rows apart from them.
icon.setTag(R.id.flexera_drawer_row, true)
val textSize = res.getDimension(R.dimen.flexera_drawer_row_text_size)
icon.layoutParams.height = rowHeight(icon.context)
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
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.
@@ -118,13 +170,17 @@ object FlexeraDrawer {
* the row grows instead.
*/
@JvmStatic
fun rowHeight(context: Context): Int {
val res = context.resources
return max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(res.getDimension(R.dimen.flexera_drawer_row_text_size) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
}
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
@@ -304,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)
}
/**
+78
View File
@@ -0,0 +1,78 @@
package app.flexera.drawer
import android.view.View
import androidx.lifecycle.lifecycleScope
import app.lawnchair.LawnchairLauncher
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_ALL_APPS
import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION
import com.android.launcher3.R
import com.android.launcher3.model.data.ItemInfo
import com.android.launcher3.popup.SystemShortcut
import com.android.launcher3.util.ComponentKey
import com.android.launcher3.views.Snackbar
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* "Hide app" in the menu a long press on an app opens in the app list.
*
* It puts the app on the hidden list Lawnchair already has: the one the app list and its search
* leave out, kept under Settings > App drawer > Hidden apps, where an app is brought back. Before
* this, hiding meant going through that settings page or through Customize. A message with Undo
* follows, because the app is gone from the list the moment it is hidden.
*/
object HideApp {
/**
* Offered for apps in the app list only: hiding does nothing to an icon on the home screen,
* so offering it there would promise something else. Not offered while the home screen is
* locked against changes, like Customize and Uninstall.
*/
@JvmField
val SHORTCUT = SystemShortcut.Factory { launcher: LawnchairLauncher, itemInfo: ItemInfo, originalView: View ->
val component = itemInfo.targetComponent
when {
itemInfo.container != CONTAINER_ALL_APPS -> null
itemInfo.itemType != ITEM_TYPE_APPLICATION || component == null -> null
PreferenceManager2.getInstance(launcher).lockHomeScreen.firstCached() -> null
else -> Shortcut(launcher, itemInfo, originalView, ComponentKey(component, itemInfo.user))
}
}
private class Shortcut(
launcher: LawnchairLauncher,
itemInfo: ItemInfo,
originalView: View,
private val app: ComponentKey,
) : SystemShortcut<LawnchairLauncher>(
R.drawable.ic_flexera_hide,
R.string.flexera_hide_app,
launcher,
itemInfo,
originalView,
) {
override fun onClick(view: View) {
val launcher = mTarget
val name = mItemInfo.title?.toString().orEmpty()
dismissTaskMenuView()
setHidden(launcher, app, hidden = true)
Snackbar.show(
launcher,
launcher.getString(R.string.flexera_app_hidden, name),
R.string.undo,
null,
) { setHidden(launcher, app, hidden = false) }
}
}
private fun setHidden(launcher: LawnchairLauncher, app: ComponentKey, hidden: Boolean) {
val hiddenApps = PreferenceManager2.getInstance(launcher).hiddenApps
val key = app.toString()
launcher.lifecycleScope.launch {
val now = hiddenApps.get().first()
hiddenApps.set(if (hidden) now + key else now - key)
}
}
}
@@ -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
@@ -50,7 +51,9 @@ import kotlin.math.roundToInt
* 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.
@@ -79,7 +82,9 @@ class WaveAlphabetView(
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 = res.getDimensionPixelSize(R.dimen.flexera_drawer_header_height)
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)
@@ -153,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)
@@ -250,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) {
@@ -275,6 +287,7 @@ class WaveAlphabetView(
override fun onDetachedFromWindow() {
viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
stopWaitingForBack()
pressAnimator?.cancel()
super.onDetachedFromWindow()
}
@@ -384,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) {
@@ -398,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
}
@@ -415,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()
@@ -643,6 +711,12 @@ class WaveAlphabetView(
/** 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
@@ -0,0 +1,29 @@
package app.flexera.home
import android.content.Context
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.Utilities
/**
* The blur of the home screen behind an open folder: whether the owner wants it, and how strong
* it is. The blur itself is the depth controller's work
* ([com.android.quickstep.util.BaseDepthControllerImpl.folderBlur]), because that already blurs
* the same views and the wallpaper for other reasons and would undo anything done past it. The
* folder's opening and closing animation moves it
* ([com.android.launcher3.folder.FolderSpringAnimatorSet]).
*/
object FolderBlur {
/**
* The blur's radius behind a fully open folder, as a share of the largest radius the
* launcher blurs with (30dp). Strong enough that the icons behind the folder are no longer
* read, which is the point: the folder is what is in focus.
*/
const val STRENGTH = 0.8f
/** Blurring a view needs Android 12. */
@JvmStatic
fun isOn(context: Context): Boolean =
Utilities.ATLEAST_S && PreferenceManager2.getInstance(context).folderBlur.firstCached()
}
+276
View File
@@ -0,0 +1,276 @@
package app.flexera.home
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherApps
import android.graphics.Canvas
import android.os.Build
import android.os.Process
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.model.data.AppInfo
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. When the choice is an app, there is no stretch: the pull brings in
* a page of the app's own ([QuickLaunchPage]), and the app opens on it. For the other actions
* the stretch stays, and far enough, or a flick, does the action when the finger lifts.
* 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()
}
}
/** The app [choice] opens, if it opens one. */
private fun appOf(choice: GestureHandlerConfig): OpenAppTarget.App? =
(choice as? GestureHandlerConfig.OpenApp)?.target as? OpenAppTarget.App
/** Opens [target]; false if it cannot be opened. */
private fun startApp(launcher: Launcher, target: OpenAppTarget.App, options: ActivityOptions): Boolean = try {
val apps = launcher.getSystemService(LauncherApps::class.java)
apps?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
apps != null
} 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)
false
}
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
val choice = choice(launcher, firstPage)
if (choice is GestureHandlerConfig.NoOp) return
val target = appOf(choice)
if (target != null) {
// 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,
)
startApp(launcher, target, options)
return
}
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
}
/** The page is going all the way in: the app is started, or the home screen comes back. */
private fun launchOnPage(launcher: Launcher, firstPage: Boolean, page: QuickLaunchPage) {
val target = appOf(choice(launcher, firstPage))
if (target == null || !startOnPage(launcher, target)) page.close()
}
/**
* Opens [target] without Android's opening animation: the app appears on the page the moment
* it has something to show. That is its last screen if it was still in memory, else its
* splash screen. (Android plays no other animation that a launcher of one's own may choose:
* asked for a plain fade, it still zooms the app in from the middle.)
*/
private fun startOnPage(launcher: Launcher, target: OpenAppTarget.App): Boolean {
val key = target.key
if (key.user != Process.myUserHandle()) {
// An app of another profile, such as a work profile, can only be started through
// LauncherApps, which takes no such flag; it opens with Android's usual animation.
return startApp(launcher, target, ActivityOptions.makeBasic())
}
// The same intent as the app's icon, so that it is the same task that comes up.
val intent = AppInfo.makeLaunchIntent(key.componentName)
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
return try {
launcher.startActivity(intent)
true
} 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)
false
}
}
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
/** The page this pull brings in, when the choice is an app; then there is no stretch. */
private var paging: QuickLaunchPage? = null
/** How fast the finger was moving towards the missing page when it lifted, in px/s. */
private var outwardVelocity = 0f
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
// While paging, the pull is the page's, not a stretch's: the pages ask these whether
// there is a pull to give back before they scroll the other way.
override fun getDistance(): Float = if (paging != null) pulled else super.getDistance()
override fun isFinished(): Boolean = if (paging != null) pulled <= 0f else super.isFinished()
override fun draw(canvas: Canvas): Boolean = if (paging != null) false else super.draw(canvas)
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
if (pulled == 0f && deltaDistance > 0f) {
val choice = choice(workspace.context, firstPage)
active = choice !is GestureHandlerConfig.NoOp &&
launcher.isInState(LauncherState.NORMAL) &&
!launcher.dragController.isDragging
paging = if (active && appOf(choice) != null) {
QuickLaunchPage.of(launcher)?.takeUnless { it.isOpening }
} else {
null
}
// A page still on its way back is caught where it is.
pulled = paging?.begin(fromLeft = atLeftEdge) ?: 0f
}
val page = paging
if (page != null) {
val before = pulled
pulled = (pulled + deltaDistance).coerceIn(0f, 1f)
page.show(pulled)
arm(pulled >= QuickLaunchPage.OPEN_DISTANCE)
return pulled - before
}
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
arm(active && pulled >= ARM_DISTANCE)
return consumed
}
override fun onFlingVelocity(velocity: Int) {
// A pull at the left side is a finger moving right, which is a positive speed.
outwardVelocity = (if (atLeftEdge) velocity else -velocity).toFloat()
flicked = active && pulled >= FLICK_DISTANCE && outwardVelocity >= flickVelocity
}
override fun onAbsorb(velocity: Int) {
if (paging == null) super.onAbsorb(velocity)
}
override fun onRelease(ev: MotionEvent) {
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
val page = paging
if (page != null) {
// As with the pages themselves: a flick decides, else how far the page is in.
val open = lifted && when {
flicked -> true
outwardVelocity <= -flickVelocity -> false
else -> armed
}
val tickNow = open && !armed
val speed = if (open) outwardVelocity else -outwardVelocity
reset()
if (tickNow) tick()
// The app is started right after the pages have finished with this touch.
page.settle(open, speed) {
workspace.post { launchOnPage(launcher, firstPage, page) }
}
return
}
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() {
val page = paging
reset()
if (page != null) page.close() else super.onRelease()
}
override fun finish() {
val page = paging
reset()
if (page != null) page.close() else super.finish()
}
private fun arm(nowArmed: Boolean) {
if (nowArmed != armed) {
armed = nowArmed
// A tick when the pull is far enough, and again when it is taken back.
tick()
}
}
private fun reset() {
pulled = 0f
active = false
armed = false
flicked = false
paging = null
outwardVelocity = 0f
}
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)
}
}
}
@@ -0,0 +1,262 @@
package app.flexera.home
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import com.android.launcher3.BaseActivity
import com.android.launcher3.InsettableFrameLayout
import com.android.launcher3.Launcher
import com.android.launcher3.util.SystemUiController
import java.util.WeakHashMap
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Quick launch's page: when a swipe past the first or the last home screen page opens an app,
* the home screen does not stretch. It moves aside with the finger, as it does for a page that
* is there, and an empty black page comes in behind it. The home screen blurs and darkens the
* further it goes. Let go far enough, or flick, and the app is started there and then, while the
* page settles over the whole screen; it appears on the page as soon as it has drawn, with none
* of Android's opening animation. Let go early, and the home screen comes back.
*
* The home screen is moved by its drawing only ([View.setAnimationMatrix] on the drag layer),
* not by its position: the touch that moves it is still being read by the pages inside it, and
* moving their position would move the finger's position in them as well.
*
* [QuickLaunch]'s edge effect drives this with the finger, from 0 (home screen) to 1 (page).
*/
class QuickLaunchPage private constructor(private val launcher: Launcher) {
companion object {
/** How far the page has to come in for letting go to open it, as a share of the width. */
const val OPEN_DISTANCE = 0.4f
/** How dark the home screen gets beside a page that is all the way in. */
private const val HOME_DIM = 0.35f
/** The page settles at least this fast, in dp per second, and within these times. */
private const val MIN_SETTLE_DP_PER_SECOND = 1500f
private const val MIN_SETTLE_MS = 150L
private const val MAX_SETTLE_MS = 300L
/**
* Once the page is across, the launcher is paused as soon as the app starts. Still in
* the foreground after this long, it never will be: nothing came, and the home screen
* comes back.
*/
private const val LAUNCH_TIMEOUT_MS = 1500L
private val pages = WeakHashMap<Launcher, QuickLaunchPage>()
/** [launcher]'s page, made when first wanted; null while the launcher has no views. */
fun of(launcher: Launcher): QuickLaunchPage? {
pages[launcher]?.let { return it }
if (launcher.rootView == null || launcher.dragLayer == null) return null
return QuickLaunchPage(launcher).also { pages[launcher] = it }
}
/** Whether [launcher]'s page is on the screen, all or part of it. */
@JvmStatic
fun isShowing(launcher: Launcher): Boolean = (pages[launcher]?.progress ?: 0f) > 0f
/** The blur for how far the page is in: quick at first, so the home screen soon recedes. */
private fun blurFor(progress: Float): Float {
val left = 1f - progress
return 1f - left * left
}
}
/** How far the page is in, from 0 (not at all) to 1 (it fills the screen). */
var progress = 0f
private set
/** Whether the page comes in from the left edge of the screen, rather than the right. */
private var fromLeft = false
/** The page is on its way all the way in, or there, with the app to follow. */
private var opening = false
/** Changes whenever what an earlier opening waits for no longer counts. */
private var generation = 0
private var animator: ValueAnimator? = null
private val shift = Matrix()
private val view = PageView(launcher)
private val minSettleSpeed = MIN_SETTLE_DP_PER_SECOND * launcher.resources.displayMetrics.density
init {
view.visibility = View.INVISIBLE
// Over the whole window, the status and navigation bars included.
val params = InsettableFrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
).apply { ignoreInsets = true }
launcher.rootView.addView(view, params)
}
/** Whether the page is busy opening an app; it then takes no new pull. */
val isOpening: Boolean
get() = opening
/**
* A pull begins at the left or the right edge. A page still on its way back from the same
* edge is caught where it is; that is returned, as how far the page is in.
*/
fun begin(fromLeft: Boolean): Float {
cancelAnimation()
if (this.fromLeft != fromLeft) {
show(0f)
this.fromLeft = fromLeft
}
return progress
}
/** Puts the page [progress] of the way in, from 0 to 1, with the home screen beside it. */
fun show(progress: Float) {
this.progress = progress.coerceIn(0f, 1f)
apply()
}
/**
* The finger has let go: the page goes all the way in ([open]) or back out, at least as fast
* as the finger was moving that way ([speed], pixels per second). Going in, [launch] runs at
* once, so that the app is on its way while the page settles; it starts the app, or calls
* [close] if there is none to start.
*/
fun settle(open: Boolean, speed: Float, launch: () -> Unit) {
cancelAnimation()
opening = open
val target = if (open) 1f else 0f
if (progress == target) {
apply()
} else {
val remaining = abs(target - progress) * view.rootWidth()
// A decelerating move starts at twice its average speed.
val duration = (2000f * remaining / max(speed, minSettleSpeed)).toLong()
val anim = ValueAnimator.ofFloat(progress, target)
anim.duration = duration.coerceIn(MIN_SETTLE_MS, MAX_SETTLE_MS)
anim.interpolator = DecelerateInterpolator()
anim.addUpdateListener { show(it.animatedValue as Float) }
anim.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
if (animator === anim) animator = null
}
})
animator = anim
anim.start()
}
if (open) opened(launch)
}
/** The home screen comes back, as after letting go too early. */
fun close() {
generation++
settle(open = false, speed = 0f) {}
}
/** The home screen is back at once, with nothing to see on the way. */
fun reset() {
generation++
cancelAnimation()
opening = false
show(0f)
}
private fun opened(launch: () -> Unit) {
val token = ++generation
// Once the app is in front, the launcher stops; the next time it is seen, it is as it was.
launcher.addEventCallback(BaseActivity.EVENT_STOPPED) {
if (token == generation) reset()
}
// Starting an app pauses the launcher, resumes it for a moment and pauses it again, so
// being resumed only means something once the start has had its time.
view.postDelayed({
if (token != generation) return@postDelayed
if (launcher.hasBeenResumed()) {
// Still in front: nothing came.
close()
} else {
// Something is in front without hiding the launcher, or is still starting.
// Should the launcher come back without having stopped, the home screen does too.
launcher.addEventCallback(BaseActivity.EVENT_RESUMED) {
if (token == generation) close()
}
}
}, LAUNCH_TIMEOUT_MS)
launch()
}
private fun cancelAnimation() {
animator?.let {
animator = null
it.cancel()
}
}
private fun apply() {
val p = progress
view.visibility = if (p > 0f) View.VISIBLE else View.INVISIBLE
view.invalidate()
val dragLayer = launcher.dragLayer
if (p > 0f) {
shift.setTranslate((if (fromLeft) 1f else -1f) * p * view.rootWidth(), 0f)
dragLayer.setAnimationMatrix(shift)
} else {
dragLayer.setAnimationMatrix(null)
}
launcher.depthController?.quickLaunchBlur?.value = blurFor(p)
// Light status bar icons once the page is most of what is seen.
launcher.systemUiController?.updateUiState(
SystemUiController.UI_STATE_QUICK_LAUNCH_PAGE,
if (p >= 0.5f) SystemUiController.FLAG_DARK_STATUS or SystemUiController.FLAG_DARK_NAV else 0,
)
}
/** The width the page and the home screen move across; known before this view's first layout. */
private fun View.rootWidth(): Int = if (width > 0) width else launcher.dragLayer.width
private inner class PageView(context: Context) : View(context) {
private val pagePaint = Paint().apply { color = Color.BLACK }
private val dimPaint = Paint().apply { color = Color.BLACK }
init {
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
}
/**
* While the page opens and the app is on its way, a touch would land on the home screen
* behind it, where nothing is where it is drawn; the page keeps it. On its way back it
* lets touches through, so that a new swipe can catch it.
*/
override fun onTouchEvent(event: MotionEvent): Boolean = opening
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
val edge = progress * w
dimPaint.alpha = (HOME_DIM * progress * 255).roundToInt()
if (fromLeft) {
canvas.drawRect(0f, 0f, edge, h, pagePaint)
canvas.drawRect(edge, 0f, w, h, dimPaint)
} else {
canvas.drawRect(w - edge, 0f, w, h, pagePaint)
canvas.drawRect(0f, 0f, w - edge, h, dimPaint)
}
}
}
}
+221 -50
View File
@@ -1,11 +1,16 @@
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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,13 +18,24 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.InstallMobile
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,6 +51,7 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -110,7 +127,7 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
val updateRows = buildList<@Composable () -> Unit> {
if (FlexeraUpdater.isSupported) {
add { UpdateStatusRow(updateState) }
add { UpdateCard(updateState) }
val notes = updateState.release()?.notes.orEmpty()
if (notes.isNotBlank()) {
add {
@@ -196,7 +213,11 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
groupRows(updateRows)
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
item {
PreferenceGroupItem {
ShareCard(scanUrl = FlexeraUpdater.LATEST_APK_URL, pageUrl = FlexeraUpdater.RELEASES_PAGE_URL)
}
}
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
groupRows(
@@ -239,88 +260,219 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
}
}
/** What the update card shows; every part may be missing. */
private class UpdateCardContent(
val title: String? = null,
val body: String? = null,
/** From 0 to 1, while a download runs. */
val progress: Float? = null,
val action: UpdateAction? = null,
)
/** The button at the bottom of the update card. */
private class UpdateAction(
val label: String,
val icon: ImageVector?,
val onClick: () -> Unit,
/** Shown as a disabled button with a spinner while something is already under way. */
val busy: Boolean = false,
)
/**
* The row that says where the updater stands and, when there is something to do, does it on tap:
* check, download, or install.
* The card that says where the updater stands and offers the next step as a large button: check,
* download (which goes on to the installation by itself), or install.
*/
@Composable
private fun UpdateStatusRow(state: UpdateState) {
private fun UpdateCard(state: UpdateState) {
val context = LocalContext.current
when (state) {
UpdateState.Idle -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_check_idle),
onClick = { FlexeraUpdater.check(context) },
// 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)
}
}
val check = UpdateAction(
label = stringResource(id = R.string.flexera_update_check),
icon = Icons.Rounded.Refresh,
onClick = { FlexeraUpdater.check(context) },
)
val download = UpdateAction(
label = stringResource(id = R.string.flexera_update_download),
icon = Icons.Rounded.Download,
onClick = { FlexeraUpdater.download(context) },
)
val content = when (state) {
UpdateState.Idle -> UpdateCardContent(
body = stringResource(id = R.string.flexera_update_check_idle),
action = check,
)
UpdateState.Checking -> PreferenceTemplate(
title = { Text(text = stringResource(id = R.string.flexera_update_checking)) },
UpdateState.Checking -> UpdateCardContent(
body = stringResource(id = R.string.flexera_update_check_idle),
action = UpdateAction(
label = stringResource(id = R.string.flexera_update_checking),
icon = null,
onClick = {},
busy = true,
),
)
UpdateState.UpToDate -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_up_to_date),
onClick = { FlexeraUpdater.check(context) },
UpdateState.UpToDate -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_up_to_date),
body = stringResource(id = R.string.flexera_update_check_idle),
action = check,
)
is UpdateState.Available -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_download, state.release.versionName),
subtitle = downloadSizeText(context, state.release),
onClick = { FlexeraUpdater.download(context) },
is UpdateState.Available -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_available, state.release.versionName),
body = downloadSizeText(context, state.release),
action = download,
)
is UpdateState.Downloading -> PreferenceTemplate(
title = { Text(text = stringResource(id = R.string.flexera_update_downloading, state.release.versionName)) },
description = {
Column {
Text(text = stringResource(id = R.string.flexera_update_progress, (state.progress * 100).toInt()))
LinearProgressIndicator(
progress = { state.progress },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
)
}
},
is UpdateState.Downloading -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_downloading, state.release.versionName),
body = stringResource(id = R.string.flexera_update_progress, (state.progress * 100).toInt()),
progress = state.progress,
)
is UpdateState.ReadyToInstall -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_install, state.release.versionName),
subtitle = stringResource(id = R.string.flexera_update_install_hint),
onClick = { FlexeraUpdater.install(context) },
is UpdateState.ReadyToInstall -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_ready, state.release.versionName),
body = stringResource(id = R.string.flexera_update_install_hint),
action = UpdateAction(
label = stringResource(id = R.string.flexera_update_install),
icon = Icons.Rounded.InstallMobile,
onClick = { FlexeraUpdater.install(context) },
),
)
is UpdateState.Failed -> {
val release = state.release
if (release == null) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = checkFailureText(state),
onClick = { FlexeraUpdater.check(context) },
UpdateCardContent(
title = stringResource(id = R.string.flexera_update_failed_title),
body = checkFailureText(state),
action = if (state.problem == CheckProblem.LOCAL_NETWORK) {
UpdateAction(
label = stringResource(id = R.string.flexera_update_allow_local_network),
icon = null,
onClick = { askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION) },
)
} else {
UpdateAction(
label = stringResource(id = R.string.flexera_update_try_again),
icon = Icons.Rounded.Refresh,
onClick = { FlexeraUpdater.check(context) },
)
},
)
} else {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_download, release.versionName),
subtitle = stringResource(
UpdateCardContent(
title = stringResource(id = R.string.flexera_update_available, release.versionName),
body = stringResource(
id = if (state.failure == UpdateFailure.WRONG_FILE) {
R.string.flexera_update_wrong_file
} else {
R.string.flexera_update_download_failed
},
),
onClick = { FlexeraUpdater.download(context) },
action = UpdateAction(
label = stringResource(id = R.string.flexera_update_try_again),
icon = Icons.Rounded.Download,
onClick = download.onClick,
),
)
}
}
}
val title = content.title
val body = content.body
val progress = content.progress
val action = content.action
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
if (body != null) {
if (title != null) Spacer(Modifier.height(4.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (progress != null) {
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
)
}
if (action != null) {
Spacer(Modifier.height(16.dp))
UpdateButton(action)
}
}
}
/** A full-width filled button, so the next step stands out from the settings rows around it. */
@Composable
private fun UpdateButton(action: UpdateAction) {
val icon = action.icon
Button(
onClick = action.onClick,
enabled = !action.busy,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
if (action.busy) {
CircularProgressIndicator(
modifier = Modifier.size(ButtonDefaults.IconSize),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
}
Text(text = action.label, style = MaterialTheme.typography.titleSmall)
}
}
/**
* 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.
* What the owner holds up for someone who wants the app: a QR code that downloads the newest
* APK ([scanUrl]), with the address of the release page under it for anyone who would rather
* type one ([pageUrl]); that one is shorter and shows all versions.
*/
@Composable
private fun ShareCard(url: String) {
private fun ShareCard(scanUrl: String, pageUrl: String) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -328,7 +480,7 @@ private fun ShareCard(url: String) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCode(
text = url,
text = scanUrl,
description = stringResource(id = R.string.flexera_about_share_qr_description),
modifier = Modifier.size(QR_CODE_SIZE),
)
@@ -340,7 +492,7 @@ private fun ShareCard(url: String) {
)
Spacer(Modifier.height(4.dp))
Text(
text = url.removePrefix("https://"),
text = pageUrl.removePrefix("https://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -393,6 +545,7 @@ private fun checkFailureText(state: UpdateState.Failed): String {
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
@@ -445,3 +598,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
@@ -75,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(),
+198 -10
View File
@@ -9,8 +9,12 @@ 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.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
@@ -22,7 +26,10 @@ import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import java.io.File
import java.io.IOException
import java.lang.ref.WeakReference
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
@@ -74,6 +81,14 @@ enum class CheckProblem {
/** 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,
@@ -137,6 +152,13 @@ object FlexeraUpdater {
/** The page listing every release, for the About screen's "All versions" link. */
const val RELEASES_PAGE_URL = "$SERVER/$RELEASE_REPOSITORY/releases"
/**
* Always the APK of the newest release: the server sends "latest" on to the newest release,
* and every release carries its APK under this one name as well (flexera/release.ps1). What
* the QR code on the About screen holds, so that scanning it starts the download.
*/
const val LATEST_APK_URL = "$SERVER/$RELEASE_REPOSITORY/releases/download/latest/FlexeraLauncher.apk"
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 30_000
@@ -148,6 +170,22 @@ object FlexeraUpdater {
private const val KEY_LAST_ATTEMPT_OK = "last_attempt_ok"
private const val KEY_NOTIFIED_VERSION = "notified_version"
/**
* When the owner last asked for the update to be installed: by tapping Download, or Install
* while Android still had to be told to let this app install apps. Kept on disk, because
* Android may end the launcher's process while the owner is in that setting.
*/
private const val KEY_INSTALL_REQUESTED_AT = "install_requested_at"
/** True while the owner was sent to Android's "Install unknown apps" setting. */
private const val KEY_AWAITING_INSTALL_PERMISSION = "awaiting_install_permission"
/**
* How long after such a request the installer may still open by itself. An installer that
* pops up hours after the owner tapped Download would come as a surprise.
*/
private const val AUTO_INSTALL_WINDOW_MS = 30L * 60 * 1000
private const val UPDATES_DIR = "updates"
private const val APK_PREFIX = "FlexeraLauncher-"
private const val CHANNEL_ID = "flexera_updates"
@@ -170,17 +208,39 @@ object FlexeraUpdater {
private var nextAutomaticCheckAt = 0L
/**
* Starts the once-a-day automatic check. Called once, from the application's onCreate.
* The activity of this app that is in front (home screen or settings), if one is. The
* installer opens from it; Android does not let an app in the background open one.
*/
@Volatile
private var frontActivity: WeakReference<Activity>? = null
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Starts the once-a-day automatic check, and lets a requested install go ahead whenever one
* of the app's screens comes to the front. Called once, from the application's onCreate.
*/
@JvmStatic
fun start(app: Application) {
if (!isSupported) return
scope.launch { restoreDownload(app) }
scope.launch {
restoreDownload(app)
// The process may have been restarted while the owner was allowing installs.
installIfRequested()
}
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) = checkIfDue(activity.applicationContext)
override fun onActivityResumed(activity: Activity) {
frontActivity = WeakReference(activity)
checkIfDue(activity.applicationContext)
installIfRequested()
}
override fun onActivityPaused(activity: Activity) {
if (frontActivity?.get() === activity) frontActivity = null
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
@@ -216,7 +276,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)
@@ -227,7 +292,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 failedCheck(error)
mutableState.value = if (automatic) before else failedCheck(app, error)
return@launch
}
@@ -247,7 +312,10 @@ object FlexeraUpdater {
}
}
/** Downloads the APK of the release that the last check found. */
/**
* Downloads the APK of the release that the last check found, then hands it to Android's
* installer straight away (see [installIfRequested]).
*/
@JvmStatic
fun download(context: Context) {
val release = when (val current = mutableState.value) {
@@ -256,6 +324,7 @@ object FlexeraUpdater {
else -> null
} ?: return
val app = context.applicationContext
requestInstall(app, awaitingPermission = false)
mutableState.value = UpdateState.Downloading(release, 0f)
scope.launch {
@@ -266,14 +335,17 @@ object FlexeraUpdater {
}
} catch (error: Exception) {
Log.w(TAG, "Update download failed", error)
clearInstallRequest(app)
mutableState.value = UpdateState.Failed(UpdateFailure.DOWNLOAD, release)
return@launch
}
if (isValidUpdate(app, target)) {
mutableState.value = UpdateState.ReadyToInstall(release, target)
installIfRequested()
} else {
Log.w(TAG, "Downloaded file is not a newer ${app.packageName}")
target.delete()
clearInstallRequest(app)
mutableState.value = UpdateState.Failed(UpdateFailure.WRONG_FILE, release)
}
}
@@ -282,18 +354,20 @@ object FlexeraUpdater {
/**
* Hands the downloaded APK to Android's installer, which asks the owner to confirm. The first
* time, Android wants the owner to allow this app to install apps; this opens that setting
* instead, and the owner taps Install again afterwards.
* instead, and the installer follows by itself when the owner comes back having allowed it.
*/
@JvmStatic
fun install(context: Context) {
val ready = mutableState.value as? UpdateState.ReadyToInstall ?: return
if (!ready.file.exists()) {
// Android cleared the cache in the meantime; find the release again.
clearInstallRequest(context)
mutableState.value = UpdateState.Idle
check(context)
return
}
if (!context.packageManager.canRequestPackageInstalls()) {
requestInstall(context, awaitingPermission = true)
val settings = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
@@ -301,6 +375,7 @@ object FlexeraUpdater {
context.startActivity(settings)
return
}
clearInstallRequest(context)
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", ready.file)
val install = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
@@ -308,6 +383,48 @@ object FlexeraUpdater {
context.startActivity(install)
}
/**
* Opens the installer when the owner asked for the update a short while ago, it is downloaded,
* and one of the app's screens is in front to open it from. Runs again each time a screen
* comes to the front, which is how the install carries on after the owner has allowed this
* app to install apps. Coming back without having allowed it drops the request, so the owner
* is not sent to that setting over and over; the Install button stays.
*/
private fun installIfRequested() {
mainHandler.post {
val activity = frontActivity?.get() ?: return@post
if (mutableState.value !is UpdateState.ReadyToInstall) return@post
val prefs = prefs(activity)
val requestedAt = prefs.getLong(KEY_INSTALL_REQUESTED_AT, 0L)
if (requestedAt == 0L) return@post
if (System.currentTimeMillis() - requestedAt !in 0 until AUTO_INSTALL_WINDOW_MS) {
clearInstallRequest(activity)
return@post
}
if (prefs.getBoolean(KEY_AWAITING_INSTALL_PERMISSION, false) &&
!activity.packageManager.canRequestPackageInstalls()
) {
clearInstallRequest(activity)
return@post
}
install(activity)
}
}
private fun requestInstall(context: Context, awaitingPermission: Boolean) {
prefs(context).edit()
.putLong(KEY_INSTALL_REQUESTED_AT, System.currentTimeMillis())
.putBoolean(KEY_AWAITING_INSTALL_PERMISSION, awaitingPermission)
.apply()
}
private fun clearInstallRequest(context: Context) {
prefs(context).edit()
.remove(KEY_INSTALL_REQUESTED_AT)
.remove(KEY_AWAITING_INSTALL_PERMISSION)
.apply()
}
/**
* Picks up an update that was downloaded before the launcher process was restarted, so the
* About screen can offer "Install" straight away. Also clears files left over from updates
@@ -346,17 +463,87 @@ object FlexeraUpdater {
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(error: Throwable): UpdateState.Failed {
val problem = when (error) {
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") }
@@ -509,6 +696,7 @@ object FlexeraUpdater {
private fun clearDownloads(context: Context) {
updatesDir(context).listFiles { file -> file.name.startsWith(APK_PREFIX) }?.forEach { it.delete() }
clearInstallRequest(context)
}
private fun apkFile(context: Context, release: FlexeraRelease): File {
@@ -0,0 +1,177 @@
package app.flexera.widget
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.WorkerThread
import com.android.launcher3.R
import com.android.launcher3.util.Executors
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.roundToInt
import org.json.JSONObject
/**
* The weather line of the clock widget: the temperature and a word for the sky at a place the
* owner has chosen.
*
* The data comes from Open-Meteo, a weather service that needs no account. It is asked only for
* a place the owner picked by name in the clock's options, never for where the phone is, so the
* launcher needs no location permission; and only while a clock with weather is in view, at most
* every [FRESH_MS]. The place's coordinates, rounded to about a kilometre, are all that is sent.
* The last answer is kept, so that the line is there at once and survives a spell offline.
*/
object ClockWeather {
/** A place found by name. [detail] tells places of one name apart: region and country. */
data class Place(val name: String, val detail: String, val latitude: Double, val longitude: Double)
/** What the service said: degrees in the unit of the phone's country, and a WMO weather code. */
data class Reading(val degrees: Int, val code: Int, val fahrenheit: Boolean, val readAt: Long)
/** Told on the main thread that a new reading has come in. */
fun interface Listener {
fun onWeatherChanged()
}
private const val TAG = "ClockWeather"
private const val FILE = "flexera_clock_weather"
private const val FORECAST = "https://api.open-meteo.com/v1/forecast"
private const val SEARCH = "https://geocoding-api.open-meteo.com/v1/search"
private const val TIMEOUT_MS = 10_000
/** A reading younger than this is not asked for again. */
private const val FRESH_MS = 45 * 60 * 1000L
/** A reading older than this is no longer shown: stale weather is worse than none. */
private const val SHOWN_MS = 3 * 60 * 60 * 1000L
/** The countries that count degrees in Fahrenheit. */
private val fahrenheitCountries = setOf("US", "BS", "BZ", "KY", "PW", "FM", "MH", "LR")
private val listeners = CopyOnWriteArraySet<Listener>()
private val asking = HashSet<String>()
private val main = Handler(Looper.getMainLooper())
fun addListener(listener: Listener) {
listeners.add(listener)
}
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** The places of this name, the likeliest first. Asks the service; throws when it cannot. */
@WorkerThread
@Throws(IOException::class)
fun search(name: String): List<Place> {
val language = Locale.getDefault().language.ifEmpty { "en" }
val answer = get("$SEARCH?name=${URLEncoder.encode(name, "UTF-8")}&count=6&language=$language&format=json")
val results = JSONObject(answer).optJSONArray("results") ?: return emptyList()
return (0 until results.length()).map { index ->
val place = results.getJSONObject(index)
val detail = listOf(place.optString("admin1"), place.optString("country"))
.filter { it.isNotEmpty() }
.joinToString(", ")
Place(place.getString("name"), detail, place.getDouble("latitude"), place.getDouble("longitude"))
}
}
/** The last reading for a place, if there is one that is still worth showing. */
fun reading(context: Context, place: Place): Reading? {
val stored = prefs(context).getString(key(place), null)?.split('|') ?: return null
if (stored.size != 4) return null
val reading = Reading(
degrees = stored[0].toIntOrNull() ?: return null,
code = stored[1].toIntOrNull() ?: return null,
fahrenheit = stored[2] == "F",
readAt = stored[3].toLongOrNull() ?: return null,
)
val age = System.currentTimeMillis() - reading.readAt
return if (age in 0..SHOWN_MS && reading.fahrenheit == usesFahrenheit()) reading else null
}
/** Asks for a new reading in the background, unless the last one is fresh or one is on its way. */
fun refreshIfStale(context: Context, place: Place) {
val app = context.applicationContext
val key = key(place)
val last = reading(app, place)
if (last != null && System.currentTimeMillis() - last.readAt < FRESH_MS) return
synchronized(asking) {
if (!asking.add(key)) return
}
Executors.THREAD_POOL_EXECUTOR.execute {
try {
val fahrenheit = usesFahrenheit()
val answer = get(
"$FORECAST?latitude=${coordinate(place.latitude)}&longitude=${coordinate(place.longitude)}" +
"&current=temperature_2m,weather_code" +
"&temperature_unit=${if (fahrenheit) "fahrenheit" else "celsius"}",
)
val current = JSONObject(answer).getJSONObject("current")
val degrees = current.getDouble("temperature_2m").roundToInt()
val code = current.getInt("weather_code")
prefs(app).edit()
.putString(key, "$degrees|$code|${if (fahrenheit) "F" else "C"}|${System.currentTimeMillis()}")
.apply()
main.post { listeners.forEach { it.onWeatherChanged() } }
} catch (e: Exception) {
// Offline, or the service is down: the last reading stays until it is too old.
Log.w(TAG, "No weather this time", e)
} finally {
synchronized(asking) { asking.remove(key) }
}
}
}
/** The line as the widget shows it, such as "6° · Overcast". */
fun line(context: Context, reading: Reading): String =
context.getString(R.string.flexera_weather_line, reading.degrees, context.getString(word(reading.code)))
/** A word for a WMO weather code (the codes Open-Meteo uses). */
private fun word(code: Int): Int = when (code) {
0 -> R.string.flexera_weather_clear
1 -> R.string.flexera_weather_mostly_clear
2 -> R.string.flexera_weather_partly_cloudy
3 -> R.string.flexera_weather_overcast
45, 48 -> R.string.flexera_weather_fog
51, 53, 55 -> R.string.flexera_weather_drizzle
56, 57, 66, 67 -> R.string.flexera_weather_freezing_rain
61, 63, 65 -> R.string.flexera_weather_rain
71, 73, 75, 77 -> R.string.flexera_weather_snow
80, 81, 82 -> R.string.flexera_weather_showers
85, 86 -> R.string.flexera_weather_snow_showers
95, 96, 99 -> R.string.flexera_weather_thunderstorm
else -> R.string.flexera_weather_overcast
}
private fun usesFahrenheit(): Boolean = Locale.getDefault().country in fahrenheitCountries
/** Two decimals, about a kilometre: all a forecast needs, and no more exact than that. */
private fun coordinate(value: Double): String = String.format(Locale.ROOT, "%.2f", value)
private fun key(place: Place): String = "${coordinate(place.latitude)},${coordinate(place.longitude)}"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private fun get(address: String): String {
val connection = URL(address).openConnection() as HttpURLConnection
try {
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
connection.setRequestProperty("Accept", "application/json")
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("The weather service answered HTTP ${connection.responseCode}")
}
return connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,306 @@
package app.flexera.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import app.flexera.widget.ClockWidgetStore.Align
import app.flexera.widget.ClockWidgetStore.Size
import app.flexera.widget.ClockWidgetStore.Weight
import app.lawnchair.ui.theme.EdgeToEdge
import app.lawnchair.ui.theme.LawnchairTheme
import com.android.launcher3.R
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The clock widget's options: where its text stands, how large and how heavy its figures are,
* and the place its weather line is for. Reached from the widget's edit button. It is a sheet
* over the home screen, which stays in view, and every choice shows on the widget at once;
* closing the sheet is all there is to finishing.
*/
class ClockWidgetConfigActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val widget = intent?.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
if (widget == AppWidgetManager.INVALID_APPWIDGET_ID) {
setResult(RESULT_CANCELED)
finish()
return
}
// The widget is fine with or without a visit here.
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget))
setContent {
LawnchairTheme {
EdgeToEdge()
var options by remember { mutableStateOf(ClockWidgetStore.options(this, widget)) }
val change = { changed: ClockWidgetStore.Options ->
options = changed
ClockWidgetStore.set(this, widget, changed)
}
ModalBottomSheet(
onDismissRequest = { finish() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
// No dimming: the clock behind the sheet is what the choices are judged by.
scrimColor = Color.Transparent,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
) {
Text(
text = stringResource(id = R.string.flexera_clock_widget),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_alignment,
choices = listOf(
Align.LEFT to R.string.flexera_clock_widget_left,
Align.CENTER to R.string.flexera_clock_widget_center,
Align.RIGHT to R.string.flexera_clock_widget_right,
),
chosen = options.align,
onChoose = { change(options.copy(align = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_size,
choices = listOf(
Size.SMALL to R.string.flexera_clock_widget_small,
Size.MEDIUM to R.string.flexera_clock_widget_medium,
Size.LARGE to R.string.flexera_clock_widget_large,
),
chosen = options.size,
onChoose = { change(options.copy(size = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_weight,
choices = listOf(
Weight.THIN to R.string.flexera_clock_widget_thin,
Weight.LIGHT to R.string.flexera_clock_widget_light,
Weight.REGULAR to R.string.flexera_clock_widget_regular,
Weight.BOLD to R.string.flexera_clock_widget_bold,
),
chosen = options.weight,
onChoose = { change(options.copy(weight = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
WeatherChoice(
place = options.weather,
onChoose = { change(options.copy(weather = it)) },
)
}
}
}
}
}
}
@Composable
private fun Heading(@StringRes text: Int) {
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
}
/** A heading with one row of choices under it, of which one is taken. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> Choice(
@StringRes heading: Int,
choices: List<Pair<T, Int>>,
chosen: T,
onChoose: (T) -> Unit,
) {
Heading(heading)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
choices.forEachIndexed { index, (value, label) ->
SegmentedButton(
selected = value == chosen,
onClick = { onChoose(value) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
) {
Text(text = stringResource(id = label), maxLines = 1)
}
}
}
}
private enum class Search { IDLE, RUNNING, NOTHING, FAILED }
/**
* The weather line: off until the owner looks a place up by name and picks it from what the
* weather service finds. The field starts with the city of the phone's time zone as a guess.
*/
@Composable
private fun WeatherChoice(place: ClockWeather.Place?, onChoose: (ClockWeather.Place?) -> Unit) {
val scope = rememberCoroutineScope()
var name by rememberSaveable { mutableStateOf(place?.name ?: timeZoneCity()) }
var found by remember { mutableStateOf(emptyList<ClockWeather.Place>()) }
var search by remember { mutableStateOf(Search.IDLE) }
val keyboard = LocalSoftwareKeyboardController.current
val focus = LocalFocusManager.current
val look = {
val wanted = name.trim()
if (wanted.isNotEmpty() && search != Search.RUNNING) {
// The places found are listed under the field, where the keyboard would cover them.
keyboard?.hide()
focus.clearFocus()
search = Search.RUNNING
scope.launch {
val result = withContext(Dispatchers.IO) { runCatching { ClockWeather.search(wanted) } }
found = result.getOrDefault(emptyList())
search = when {
result.isFailure -> Search.FAILED
found.isEmpty() -> Search.NOTHING
else -> Search.IDLE
}
}
}
}
Heading(R.string.flexera_clock_widget_weather)
Text(
text = if (place == null) {
stringResource(id = R.string.flexera_clock_widget_weather_off)
} else {
stringResource(id = R.string.flexera_clock_widget_weather_on, placeLabel(place))
},
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(text = stringResource(id = R.string.flexera_clock_widget_weather_place)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { look() }),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(12.dp))
Button(onClick = { look() }) {
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_search))
}
}
when (search) {
Search.RUNNING -> LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Search.NOTHING -> Note(R.string.flexera_clock_widget_weather_nothing)
Search.FAILED -> Note(R.string.flexera_clock_widget_weather_failed)
Search.IDLE -> Unit
}
found.forEach { candidate ->
Text(
text = placeLabel(candidate),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.clickable {
onChoose(candidate)
name = candidate.name
found = emptyList()
}
.padding(vertical = 14.dp),
)
}
if (place != null) {
TextButton(onClick = { onChoose(null) }) {
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_remove))
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.flexera_clock_widget_weather_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun Note(@StringRes text: Int) {
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
private fun placeLabel(place: ClockWeather.Place): String =
if (place.detail.isEmpty()) place.name else "${place.name}, ${place.detail}"
/** The city a time zone is named after ("Europe/Zagreb" gives "Zagreb"); empty for zones without one. */
private fun timeZoneCity(): String {
val id = TimeZone.getDefault().id
return if ('/' in id) id.substringAfterLast('/').replace('_', ' ') else ""
}
@@ -0,0 +1,23 @@
package app.flexera.widget
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import com.android.launcher3.BuildConfig
/**
* The launcher's own clock widget: the time, large, with the date under it. Android knows it as
* an ordinary app widget; the launcher draws it itself with a [ClockWidgetView]. It needs no
* setting up; what the owner may choose for it is kept in [ClockWidgetStore]. See [FlexeraWidgets].
*/
class ClockWidgetProvider : AppWidgetProvider() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
appWidgetIds.forEach { ClockWidgetStore.delete(context, it) }
}
companion object {
@JvmField
val componentName = ComponentName(BuildConfig.APPLICATION_ID, ClockWidgetProvider::class.java.name)
}
}
@@ -0,0 +1,111 @@
package app.flexera.widget
import android.content.Context
import android.content.SharedPreferences
import java.util.concurrent.CopyOnWriteArraySet
/**
* What the owner has chosen for each clock widget on the home screen: where its text stands, how
* large and how heavy its figures are, and the place its weather line is for, if it has one.
* Kept per widget, so that two clocks can differ.
*/
object ClockWidgetStore {
/** Which side of the widget the time and the date stand on. */
enum class Align(val key: String) { LEFT("left"), CENTER("center"), RIGHT("right") }
/** How large the figures are, measured against the icons (see ClockWidgetView). */
enum class Size(val key: String) { SMALL("small"), MEDIUM("medium"), LARGE("large") }
/** How heavy the figures are drawn; [value] is the font weight, from 100 to 900. */
enum class Weight(val key: String, val value: Int) {
THIN("thin", 100),
LIGHT("light", 300),
REGULAR("regular", 400),
BOLD("bold", 700),
}
data class Options(
val align: Align = Align.LEFT,
val size: Size = Size.MEDIUM,
val weight: Weight = Weight.LIGHT,
/** The place of the weather line; null for a clock without weather, which is how it starts. */
val weather: ClockWeather.Place? = null,
)
/** Told, on the thread that made the change, that a widget's options are different now. */
fun interface Listener {
fun onClockOptionsChanged(appWidgetId: Int)
}
private val listeners = CopyOnWriteArraySet<Listener>()
fun addListener(listener: Listener) {
listeners.add(listener)
}
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
fun options(context: Context, appWidgetId: Int): Options {
val prefs = prefs(context)
val align = prefs.getString(ALIGN + appWidgetId, null)
val size = prefs.getString(SIZE + appWidgetId, null)
val weight = prefs.getString(WEIGHT + appWidgetId, null)
val place = prefs.getString(PLACE + appWidgetId, null)
return Options(
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
weight = Weight.entries.firstOrNull { it.key == weight } ?: Weight.LIGHT,
weather = place?.let {
ClockWeather.Place(
name = it,
detail = prefs.getString(PLACE_DETAIL + appWidgetId, "").orEmpty(),
latitude = prefs.getFloat(LATITUDE + appWidgetId, 0f).toDouble(),
longitude = prefs.getFloat(LONGITUDE + appWidgetId, 0f).toDouble(),
)
},
)
}
fun set(context: Context, appWidgetId: Int, options: Options) {
val edit = prefs(context).edit()
.putString(ALIGN + appWidgetId, options.align.key)
.putString(SIZE + appWidgetId, options.size.key)
.putString(WEIGHT + appWidgetId, options.weight.key)
val place = options.weather
if (place == null) {
edit.remove(PLACE + appWidgetId).remove(PLACE_DETAIL + appWidgetId)
.remove(LATITUDE + appWidgetId).remove(LONGITUDE + appWidgetId)
} else {
edit.putString(PLACE + appWidgetId, place.name)
.putString(PLACE_DETAIL + appWidgetId, place.detail)
.putFloat(LATITUDE + appWidgetId, place.latitude.toFloat())
.putFloat(LONGITUDE + appWidgetId, place.longitude.toFloat())
}
edit.apply()
listeners.forEach { it.onClockOptionsChanged(appWidgetId) }
}
/** Forgets the options of a widget that left the home screen. */
fun delete(context: Context, appWidgetId: Int) {
val edit = prefs(context).edit()
for (key in listOf(ALIGN, SIZE, WEIGHT, PLACE, PLACE_DETAIL, LATITUDE, LONGITUDE)) {
edit.remove(key + appWidgetId)
}
edit.apply()
}
private fun prefs(context: Context): SharedPreferences =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private const val FILE = "flexera_clock_widget"
private const val ALIGN = "align_"
private const val SIZE = "size_"
private const val WEIGHT = "weight_"
private const val PLACE = "place_"
private const val PLACE_DETAIL = "place_detail_"
private const val LATITUDE = "latitude_"
private const val LONGITUDE = "longitude_"
}
@@ -0,0 +1,409 @@
package app.flexera.widget
import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.provider.AlarmClock
import android.text.TextPaint
import android.text.format.DateFormat
import android.util.AttributeSet
import android.util.Log
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import app.flexera.widget.ClockWidgetStore.Align
import app.flexera.widget.ClockWidgetStore.Size
import com.android.launcher3.R
import com.android.launcher3.util.Themes
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
import kotlin.math.max
import kotlin.math.min
/**
* What the clock widget ([ClockWidgetProvider]) looks like: the time in light figures and the
* date under it, written straight onto the wallpaper in the colour of the icon labels.
*
* Like the picture widget it keeps to the box of the icons in its cells ([IconFrame]). The
* owner chooses which side of that box the text stands on, or its middle, and how large the
* figures are ([ClockWidgetStore], set from the widget's edit button). The sizes are measured
* against the icons, so that the clock keeps its proportion to them on any grid: small figures
* are about half an icon tall, medium ones most of an icon, large ones taller than an icon. The
* widget's own height and width are the limit in every case. Up and down the text stands in the
* middle of the widget's own cells, which is what the outline shows while the widget is edited.
*
* A clock for which the owner has picked a place has a third line, the weather there
* ([ClockWeather]).
*
* A tap opens the phone's clock app.
*/
class ClockWidgetView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs),
ClockWidgetStore.Listener,
ClockWeather.Listener {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
/** True while this is only a sample of the widget, in the list of widgets. */
private var sample = true
private var options = ClockWidgetStore.Options()
private val frame = RectF()
/** What the figures are measured against: the height of what is seen of an icon. */
private var iconHeight = 0f
private val ink = Rect()
private val now = Calendar.getInstance()
private var time = ""
private var date = ""
/** The weather line; empty for a clock without weather or while none is known. */
private var weather = ""
private val timePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
// Figures of one width, so that the time does not shift sideways from minute to minute.
fontFeatureSettings = "tnum"
}
private val datePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = weight(DATE_WEIGHT)
}
private val weatherPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = weight(WEATHER_WEIGHT)
}
// Where the lines go; worked out in arrange().
private var timeX = 0f
private var timeBaseline = 0f
private var dateX = 0f
private var dateBaseline = 0f
private var weatherX = 0f
private var weatherBaseline = 0f
private var listening = false
private val clock = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) now.timeZone = TimeZone.getDefault()
tell()
}
}
init {
setWillNotDraw(false)
// Says to Launcher3 that this widget shapes itself (see RoundedCornerEnforcement).
id = android.R.id.background
clipToOutline = true
colour()
}
/**
* On the home screen the text has the colour of the icon labels, which follows the
* wallpaper. The list of widgets is a sheet of its own, so a sample there takes the sheet's
* text colour.
*/
private fun colour() {
val color = if (sample) {
Themes.getAttrColor(context, android.R.attr.textColorPrimary)
} else {
Themes.getAttrColor(context, R.attr.workspaceTextColor)
}
val paints = listOf(timePaint, datePaint, weatherPaint)
paints.forEach { it.color = color }
// Light text on a wallpaper gets the soft shadow the icon labels have.
if (!sample && Color.luminance(color) > 0.5f) {
val density = resources.displayMetrics.density
paints.forEach {
it.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
}
} else {
paints.forEach { it.clearShadowLayer() }
}
}
/** Takes the owner's choices for this widget: how the figures are drawn, and the weather. */
private fun takeOptions() {
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
timePaint.typeface = weight(options.weight.value)
readWeather()
}
/** Shows the last known weather for the chosen place and asks for newer if it is due. */
private fun readWeather() {
val place = options.weather
weather = if (place == null) {
""
} else {
if (isAttachedToWindow && !sample) ClockWeather.refreshIfStale(context, place)
ClockWeather.reading(context, place)?.let { ClockWeather.line(context, it) }.orEmpty()
}
describe()
}
override fun onWeatherChanged() {
val before = weather
readWeather()
if (weather != before) {
arrange()
invalidate()
}
}
/** 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
takeOptions()
colour()
if (sample) {
// In the list of widgets a tap belongs to the list.
setOnClickListener(null)
isClickable = false
} else {
setOnClickListener { openClock() }
}
arrange()
invalidate()
}
override fun onClockOptionsChanged(appWidgetId: Int) {
if (sample || appWidgetId != this.appWidgetId) return
takeOptions()
arrange()
invalidate()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ClockWidgetStore.addListener(this)
ClockWeather.addListener(this)
if (!listening) {
val changes = IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
addAction(Intent.ACTION_LOCALE_CHANGED)
}
ContextCompat.registerReceiver(context, clock, changes, ContextCompat.RECEIVER_NOT_EXPORTED)
listening = true
}
now.timeZone = TimeZone.getDefault()
takeOptions()
tell()
arrange()
}
override fun onDetachedFromWindow() {
ClockWidgetStore.removeListener(this)
ClockWeather.removeListener(this)
if (listening) {
context.unregisterReceiver(clock)
listening = false
}
super.onDetachedFromWindow()
}
override fun onVisibilityAggregated(isVisible: Boolean) {
super.onVisibilityAggregated(isVisible)
// Android does not announce the minutes to an app in the background; catch up, with the
// time and with the weather.
if (isVisible) {
tell()
onWeatherChanged()
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
val aligned = IconFrame.of(this)
if (aligned != null) {
frame.set(aligned.left, aligned.top, aligned.right, aligned.bottom)
iconHeight = aligned.visibleIconSize
} else {
// In the list of widgets: the whole view with a little air, and a made-up icon.
val margin = min(width, height) * LOOSE_MARGIN_SHARE
frame.set(margin, margin, width - margin, height - margin)
iconHeight = frame.height() * LOOSE_ICON_SHARE
}
arrange()
}
/** Reads the clock and shows what it says. */
private fun tell() {
now.timeInMillis = System.currentTimeMillis()
val locale = Locale.getDefault()
// The phone's own choice of 12 or 24 hours, without "AM" and "PM": the date line and
// the daylight say which half of the day it is.
val hours = if (DateFormat.is24HourFormat(context)) "Hm" else "hm"
val timePattern = DateFormat.getBestDateTimePattern(locale, hours).replace("a", "").trim()
val datePattern = DateFormat.getBestDateTimePattern(locale, "EEEEdMMMM")
val newTime = DateFormat.format(timePattern, now).toString()
val newDate = DateFormat.format(datePattern, now).toString()
if (newTime == time && newDate == date) return
time = newTime
date = newDate
describe()
arrange()
invalidate()
}
/** What a screen reader says for the widget: everything it shows. */
private fun describe() {
contentDescription = listOf(time, date, weather).filter { it.isNotEmpty() }.joinToString(", ")
}
/** Sizes the two lines for the frame and the owner's choices, and works out where they go. */
private fun arrange() {
val width = frame.width()
if (width <= 0f || frame.height() <= 0f || time.isEmpty()) return
val scaled = resources.displayMetrics.scaledDensity
// Up and down, the room is the widget's own cells; in the list of widgets, the frame.
val roomTop = if (sample) frame.top else 0f
val roomHeight = if (sample) frame.height() else height.toFloat()
// How tall the figures would like to be.
val wanted = iconHeight * when (options.size) {
Size.SMALL -> SMALL_IN_ICONS
Size.MEDIUM -> MEDIUM_IN_ICONS
Size.LARGE -> LARGE_IN_ICONS
}
// The lines under the figures go with them in size, within the sizes that read well, and
// are never wider than the frame.
var smallSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
datePaint.textSize = smallSize
weatherPaint.textSize = smallSize
val widest = max(datePaint.measureText(date), weatherPaint.measureText(weather))
if (widest > width) {
smallSize *= width / widest
datePaint.textSize = smallSize
weatherPaint.textSize = smallSize
}
// What is seen of a line of words is its capitals' height; the room a font keeps above
// and below them would push the text off the middle.
val smallHeight = smallSize * CAPITALS_IN_TEXT_SIZE
val gap = smallSize * GAP_IN_DATE_SIZES
val lineGap = smallSize * LINE_GAP_IN_DATE_SIZES
val under = gap + smallHeight + if (weather.isEmpty()) 0f else lineGap + smallHeight
// The figures: as tall as wanted, unless the room above the other lines or the frame's
// width is less. Measured on the figures themselves, not on the line they stand in.
timePaint.textSize = MEASURE_SIZE
timePaint.getTextBounds(time, 0, time.length, ink)
val inkHeightAtMeasure = max(1, ink.height()).toFloat()
val inkWidthAtMeasure = max(1, ink.width()).toFloat()
val figures = min(wanted, min(roomHeight - under, width * inkHeightAtMeasure / inkWidthAtMeasure))
timePaint.textSize = max(1f, MEASURE_SIZE * figures / inkHeightAtMeasure)
timePaint.getTextBounds(time, 0, time.length, ink)
// All lines together, in the middle of the room.
val block = ink.height() + under
val top = roomTop + (roomHeight - block) / 2f
timeBaseline = top - ink.top
dateBaseline = top + ink.height() + gap + smallHeight
weatherBaseline = dateBaseline + lineGap + smallHeight
// Sideways it is the figures' own edges that meet the frame's, not the space the font
// leaves beside them.
val dateLength = datePaint.measureText(date)
val weatherLength = weatherPaint.measureText(weather)
when (options.align) {
Align.LEFT -> {
timeX = frame.left - ink.left
dateX = frame.left
weatherX = frame.left
}
Align.CENTER -> {
timeX = frame.centerX() - ink.exactCenterX()
dateX = frame.centerX() - dateLength / 2f
weatherX = frame.centerX() - weatherLength / 2f
}
Align.RIGHT -> {
timeX = frame.right - ink.right
dateX = frame.right - dateLength
weatherX = frame.right - weatherLength
}
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (frame.isEmpty || time.isEmpty()) return
canvas.drawText(time, timeX, timeBaseline, timePaint)
canvas.drawText(date, dateX, dateBaseline, datePaint)
if (weather.isNotEmpty()) canvas.drawText(weather, weatherX, weatherBaseline, weatherPaint)
}
private fun openClock() {
val alarms = Intent(AlarmClock.ACTION_SHOW_ALARMS)
try {
// The clock app's own front door; its alarm list needs a permission of its own.
val clockApp = context.packageManager.resolveActivity(alarms, 0)?.activityInfo?.packageName
val intent = clockApp?.let(context.packageManager::getLaunchIntentForPackage) ?: return
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (e: RuntimeException) {
Log.w(TAG, "The clock app could not be opened", e)
}
}
/** The phone's own font at a weight from 100 (thin) to 900. Android 8 only knows a few. */
private fun weight(weight: Int): Typeface =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Typeface.create(Typeface.DEFAULT, weight, false)
} else {
val family = when {
weight <= 100 -> "sans-serif-thin"
weight <= 300 -> "sans-serif-light"
weight <= 400 -> "sans-serif"
weight <= 500 -> "sans-serif-medium"
else -> "sans-serif-black"
}
Typeface.create(family, Typeface.NORMAL)
}
private companion object {
const val TAG = "ClockWidgetView"
const val DATE_WEIGHT = 500
const val WEATHER_WEIGHT = 400
/**
* How tall the figures are for each size, in icon heights. (The first version filled
* the widget, about 1.7 icons on a widget two rows high, which the owner found huge.)
*/
const val SMALL_IN_ICONS = 0.5f
const val MEDIUM_IN_ICONS = 0.8f
const val LARGE_IN_ICONS = 1.25f
/** The date's text size as a share of the figures' height, and its limits. */
const val DATE_IN_FIGURES = 0.26f
const val DATE_MIN_SP = 12f
const val DATE_MAX_SP = 18f
/** How tall a font's capitals are, as a share of its text size. */
const val CAPITALS_IN_TEXT_SIZE = 0.72f
/** The space between the figures and the date, and between the lines under them, in date text sizes. */
const val GAP_IN_DATE_SIZES = 0.8f
const val LINE_GAP_IN_DATE_SIZES = 0.65f
const val MEASURE_SIZE = 200f
const val LOOSE_MARGIN_SHARE = 0.12f
const val LOOSE_ICON_SHARE = 0.45f
const val SHADOW_RADIUS_DP = 3f
const val SHADOW_DROP_DP = 1f
const val SHADOW_COLOR = 0x59000000
}
}
@@ -0,0 +1,119 @@
package app.flexera.widget
import android.appwidget.AppWidgetProviderInfo
import android.content.ComponentName
import android.graphics.Rect
import android.view.View
import android.widget.FrameLayout
import com.android.launcher3.DeviceProfile
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
import com.android.launcher3.model.data.LauncherAppWidgetInfo
import com.android.launcher3.widget.LauncherAppWidgetHostView
import com.android.launcher3.widget.PendingAppWidgetHostView
import kotlin.math.max
import kotlin.math.roundToInt
/**
* The widgets that come with the launcher: Pictures ([PictureWidgetProvider]) and Clock
* ([ClockWidgetProvider]).
*
* Android knows each as an ordinary app widget, so they are added, moved, resized and removed
* like any other. What they show is not sent through Android, though: the launcher draws them
* itself with a view of its own, which is what lets them line up with the icons ([IconFrame]).
* This is where Launcher3's widget code asks about them, and about the room every other widget
* keeps in its cells, which by default lines those up with the icons as well.
*/
object FlexeraWidgets {
private val noInset = Rect()
private fun isOwn(provider: ComponentName?): Boolean =
provider == PictureWidgetProvider.componentName || provider == ClockWidgetProvider.componentName
/**
* The room a widget's cells keep free around it, on each side; one cell is [cellWidth] by
* [cellHeight]. The launcher's own widgets take none, because they place what they draw
* against the icons of their cells by themselves ([IconFrame]); every other widget gets
* [appWidgetPadding].
*/
@JvmStatic
fun cellInset(child: View, profile: DeviceProfile, cellWidth: Int, cellHeight: Int): Rect {
val info = child.tag as? LauncherAppWidgetInfo
if (info != null && isOwn(info.providerName)) return noInset
return appWidgetPadding(profile, cellWidth, cellHeight)
}
/** [appWidgetPadding] for the cells of [profile]'s home screen. */
@JvmStatic
fun appWidgetPadding(profile: DeviceProfile): Rect {
val cell = profile.cellSize
return appWidgetPadding(profile, cell.x, cell.y)
}
/**
* The room a widget of another app keeps free inside its cells, on each side, where one
* cell is [cellWidth] by [cellHeight]. Lined up with the icons (the default), it is the
* distance from the cells' edges to the edges of the icons that would sit in them: the
* same box the launcher's own widgets draw in ([IconFrame]), so that every widget's edges
* meet the edges of the icons above, below and beside it. Otherwise it is Launcher3's own,
* which the Widget padding setting scales.
*/
@JvmStatic
fun appWidgetPadding(profile: DeviceProfile, cellWidth: Int, cellHeight: Int): Rect {
if (!profile.alignWidgetsWithIcons || cellWidth <= 0 || cellHeight <= 0) {
return profile.widgetPadding
}
// As in IconFrame: the icon is centred sideways in its cell and sits a set distance
// below the cell's top; what is seen of it leaves a little air inside its square.
val icon = profile.iconSizePx.toFloat()
val air = icon * (1f - ICON_VISIBLE_AREA_FACTOR) / 2f
val iconTop = if (profile.cellYPaddingPx >= 0) {
profile.cellYPaddingPx.toFloat()
} else {
max(0f, (cellHeight - profile.cellHeightPx) / 2f)
}
val side = max(0, ((cellWidth - icon) / 2f + air).roundToInt())
return Rect(
side,
max(0, (iconTop + air).roundToInt()),
side,
max(0, (cellHeight - iconTop - icon + air).roundToInt()),
)
}
/**
* Puts the launcher's own view into a widget host view when the widget is one of the
* launcher's, 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? {
val provider = info?.provider
if (!isOwn(provider)) return null
// A widget that still waits to be set up draws its own "tap to set up".
if (host is PendingAppWidgetHostView) return null
val pictures = provider == PictureWidgetProvider.componentName
var view: View? = null
for (i in host.childCount - 1 downTo 0) {
val child = host.getChildAt(i)
val wanted = if (pictures) child is PictureWidgetView else child is ClockWidgetView
if (wanted && view == null) view = child else host.removeViewAt(i)
}
if (view == null) {
view = if (pictures) PictureWidgetView(host.context) else ClockWidgetView(host.context)
host.addView(
view,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
}
when (view) {
is PictureWidgetView -> view.bind(appWidgetId)
is ClockWidgetView -> view.bind(appWidgetId)
}
return view
}
}
@@ -0,0 +1,83 @@
package app.flexera.widget
import android.content.Context
import android.content.ContextWrapper
import android.view.View
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.views.ActivityContext
import kotlin.math.max
import kotlin.math.min
/**
* The box around the icons that would sit in the cells a widget covers: from the left edge of
* the first column's icon to the right edge of the last column's, and from the top of the first
* row's icon to the bottom of the last row's. A widget of the launcher's own that keeps what it
* draws inside this box lines up with the icons beside, above and below it.
*/
internal class IconFrame private constructor(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
/** The size of the square an icon is drawn in. */
val iconSize: Float,
/** The size of what is seen of an icon; the rest of the square is room for its shadow. */
val visibleIconSize: Float,
) {
companion object {
/**
* The box for the widget that [view] fills, in [view]'s own coordinates. Null when
* [view] is not inside a widget on the home screen (in the list of widgets, say).
*/
fun of(view: View): IconFrame? {
val host = view.parent as? View ?: return null
val lp = host.layoutParams as? CellLayoutLayoutParams ?: return null
val container = host.parent as? ShortcutAndWidgetContainer ?: return null
val profile = activityContext(view.context)?.deviceProfile ?: return null
if (lp.cellHSpan <= 0 || lp.cellVSpan <= 0) return null
// The cells the widget covers, measured from the corner of the host view. Undoes
// what CellLayoutLayoutParams.setup took off them.
val cell = profile.cellSize
val inset = FlexeraWidgets.cellInset(host, profile, cell.x, cell.y)
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)
}
val visible = icon * ICON_VISIBLE_AREA_FACTOR
val air = (icon - visible) / 2f
val l = spanLeft + iconLeft + air - view.left
val r = spanLeft + spanWidth - iconLeft - air - view.left
val t = spanTop + iconTop + air - view.top
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - view.top
if (r - l < 1f || b - t < 1f) return null
return IconFrame(l, t, r, b, icon, visible)
}
private fun activityContext(context: Context): ActivityContext? {
var current: Context? = context
while (current != null) {
if (current is ActivityContext) return current
current = (current as? ContextWrapper)?.baseContext
}
return null
}
}
}
@@ -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,33 @@
package app.flexera.widget
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import com.android.launcher3.BuildConfig
/**
* 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]. See [FlexeraWidgets].
*/
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)
}
}
@@ -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,531 @@
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.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.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.util.Executors
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 aligned = IconFrame.of(this)
if (aligned != null) {
left = aligned.left
top = aligned.top
right = aligned.right
bottom = aligned.bottom
corner = aligned.visibleIconSize / 2f
iconSize = aligned.iconSize
}
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()
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
}
}
+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.6.1
versionName=0.15.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
@@ -283,7 +283,8 @@ class LawnchairLauncher : QuickstepLauncher() {
super.getSupportedShortcuts(container),
Stream.concat(
Stream.of(LawnchairShortcut.UNINSTALL, LawnchairShortcut.CUSTOMIZE, LawnchairShortcut.OPEN_IN_STORE),
Stream.of(LawnchairShortcut.PAUSE_APPS),
// Flexera Launcher: "Hide app", which its factory offers in the app list only.
Stream.of(LawnchairShortcut.PAUSE_APPS, app.flexera.drawer.HideApp.SHORTCUT),
),
)
@@ -517,7 +518,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() }
}
}
@@ -224,6 +224,9 @@ class PreferenceManager @Inject constructor(
// 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,34 @@ 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,
)
// Flexera Launcher: whether the edges of every widget meet the edges of the icons in the
// rows and columns it covers, as the launcher's own widgets do
// (app.flexera.widget.FlexeraWidgets.appWidgetPadding). Read with the grid.
val alignWidgetsWithIcons = preference(
key = booleanPreferencesKey(name = "flexera_align_widgets_with_icons"),
defaultValue = true,
onSet = { reloadHelper.reloadGrid() },
)
// Flexera Launcher: whether the home screen behind an open folder is blurred
// (app.flexera.home.FolderBlur). Read when a folder opens, so nothing has to be reloaded.
val folderBlur = preference(
key = booleanPreferencesKey(name = "flexera_folder_blur"),
defaultValue = true,
)
val autoUpdaterNightly = preference(
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
@@ -900,10 +928,16 @@ class PreferenceManager2 @Inject constructor(
.onEach { cachedPreferences = it }
.launchIn(scope)
// Flexera Launcher: in the five collectors below, distinctUntilChanged comes before
// drop(1). Upstream has them the other way round, which lets the first emission after
// the dropped one through whatever its value: the first write of any preference after
// the launcher started then reloaded the model, rebuilt the grid three times and
// recreated the launcher. Hiding an app from the app list is such a write, made while
// the owner looks at the launcher.
initializeIconShape(iconShape.firstCached(this))
iconShape.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { shape ->
initializeIconShape(shape)
L3ThemeManager.INSTANCE.get(context)
@@ -912,14 +946,14 @@ class PreferenceManager2 @Inject constructor(
.launchIn(scope)
hotseatMode.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { reloadHelper.restart() }
.launchIn(scope)
isHotseatEnabled.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach {
reloadHelper.recreate()
reloadHelper.reloadGrid()
@@ -927,14 +961,14 @@ class PreferenceManager2 @Inject constructor(
.launchIn(scope)
hotseatQsbProvider.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { reloadHelper.recreate() }
.launchIn(scope)
enableLabelInDock.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { reloadHelper.reloadGrid() }
.launchIn(scope)
}
@@ -33,6 +33,7 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
import app.lawnchair.ui.preferences.navigation.GeneralIconShape
import com.android.launcher3.R
import com.android.launcher3.Utilities
@Composable
fun FolderPreferences(
@@ -75,6 +76,14 @@ fun FolderPreferences(
valueRange = 0F..1F,
showAsPercentage = true,
)
// Flexera Launcher: blurring a view needs Android 12.
if (Utilities.ATLEAST_S) {
SwitchPreference(
adapter = prefs2.folderBlur.getAdapter(),
label = stringResource(id = R.string.flexera_folder_blur),
description = stringResource(id = R.string.flexera_folder_blur_description),
)
}
}
PreferenceGroup(heading = stringResource(id = R.string.grid)) {
SliderPreference(
@@ -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(),
@@ -285,13 +285,23 @@ fun HomeScreenPreferences(
label = stringResource(id = R.string.force_widget_resize_label),
description = stringResource(id = R.string.force_widget_resize_description),
)
SliderPreference(
label = stringResource(id = R.string.widget_padding_label),
adapter = prefs2.widgetPaddingFactor.getAdapter(),
step = 0.05f,
valueRange = 0F..2F,
showAsPercentage = true,
// Flexera Launcher: widgets lined up with the icons; Lawnchair's padding is for
// when they are not (app.flexera.widget.FlexeraWidgets.appWidgetPadding).
val alignWidgetsAdapter = prefs2.alignWidgetsWithIcons.getAdapter()
SwitchPreference(
adapter = alignWidgetsAdapter,
label = stringResource(id = R.string.flexera_align_widgets),
description = stringResource(id = R.string.flexera_align_widgets_description),
)
ExpandAndShrink(visible = !alignWidgetsAdapter.state.value) {
SliderPreference(
label = stringResource(id = R.string.widget_padding_label),
adapter = prefs2.widgetPaddingFactor.getAdapter(),
step = 0.05f,
valueRange = 0F..2F,
showAsPercentage = true,
)
}
}
}
}
@@ -32,6 +32,7 @@ import android.view.ViewTreeObserver;
import androidx.annotation.VisibleForTesting;
import com.android.launcher3.AbstractFloatingView;
import com.android.launcher3.BaseActivity;
import com.android.launcher3.LauncherState;
import com.android.launcher3.Utilities;
@@ -220,6 +221,17 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
* Clears a stuck workspace icon blur after resume when settled on home.
*/
public void clearStuckBlurOnResumeIfHome() {
// Flexera Launcher: a folder blur with no folder on the screen, open or still closing,
// is a stuck one too. It should not come about; this makes sure it could not stay.
if (folderBlur.getValue() > 0f && AbstractFloatingView.getAnyView(
mLauncher, AbstractFloatingView.TYPE_FOLDER) == null) {
folderBlur.setValue(0f);
}
// Flexera Launcher: likewise quick launch's blur with its page gone.
if (quickLaunchBlur.getValue() > 0f
&& !app.flexera.home.QuickLaunchPage.isShowing(mLauncher)) {
quickLaunchBlur.setValue(0f);
}
if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
&& !mLauncher.getStateManager().isInTransition()) {
clearWorkspaceRenderEffects();
@@ -227,10 +239,18 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
}
}
@Override
protected boolean isDepthEffectEnabled() {
return mEnableDepth;
}
@Override
protected void applyDepthAndBlur() {
try {
if (LawnchairQuickstepCompat.ATLEAST_R && mEnableDepth) {
// Flexera Launcher: the blur behind an open folder is a setting of its own, and
// also comes where the wallpaper's zoom is switched off.
if (LawnchairQuickstepCompat.ATLEAST_R
&& (mEnableDepth || folderBlurNeedsApplying())) {
ensureDependencies();
super.applyDepthAndBlur();
} else if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
@@ -90,6 +90,17 @@ public class BaseDepthControllerImpl implements BaseDepthController {
public final MultiProperty widgetDepth;
/** Property to set just the wallpaper zoom for folder expansion. */
public final MultiProperty folderZoom;
/**
* Flexera Launcher: property to set the blur behind an open folder, from 0 to 1. Unlike the
* depth it comes with no zoom, and it reaches the icons as well as the wallpaper.
*/
public final MultiProperty folderBlur;
/**
* Flexera Launcher: property to set the blur of the home screen while quick launch's page
* slides in over it (app.flexera.home.QuickLaunchPage), from 0 to 1. It shares the folder
* blur's machinery; the stronger of the two wins.
*/
public final MultiProperty quickLaunchBlur;
/**
* Blur radius when completely zoomed out, in pixels.
@@ -106,6 +117,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
private float mDepth;
// Used just for animating wallpaper zoom without affecting blur.
private final AnimatedFloat mWallpaperZoomOnly = new AnimatedFloat(this::applyDepthAndBlur);
// Flexera Launcher: the blur behind an open folder, see folderBlur, or behind quick
// launch's page, see quickLaunchBlur.
private final AnimatedFloat mFolderBlur = new AnimatedFloat(this::applyDepthAndBlur);
// Whether a folder blur is on the launcher's surface, and so has to come off again.
private boolean mFolderBlurApplied;
protected SurfaceControl mBaseSurface;
@@ -164,6 +180,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
} else {
folderZoom = null;
}
// Flexera Launcher
MultiPropertyFactory<AnimatedFloat> backdropBlur =
new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 2, Float::max);
folderBlur = backdropBlur.get(0);
quickLaunchBlur = backdropBlur.get(1);
// mEarlyWakeupInfo.token = new Binder();
// mEarlyWakeupInfo.trace = BaseDepthControllerImpl.class.getName();
@@ -184,6 +205,58 @@ public class BaseDepthControllerImpl implements BaseDepthController {
return folderZoom;
}
@Override
public MultiProperty getFolderBlur() {
return folderBlur;
}
@Override
public MultiProperty getQuickLaunchBlur() {
return quickLaunchBlur;
}
/** Flexera Launcher: the blur behind an open folder as it is now, from 0 to 1. */
private float folderBlurAmount() {
return Utilities.boundToRange(mFolderBlur.value, 0f, 1f);
}
/**
* Flexera Launcher: true while a folder blur is wanted, and after that until it has been
* taken off the launcher's surface again.
*/
protected boolean folderBlurNeedsApplying() {
return folderBlurAmount() > 0f || mFolderBlurApplied;
}
/** Flexera Launcher: false where the owner switched the wallpaper's zoom off. */
protected boolean isDepthEffectEnabled() {
return true;
}
/**
* Flexera Launcher: blurs the icons and widgets behind an open folder. That works without
* blur across windows as well, so also where the wallpaper has to stay sharp.
*
* @return {@code false} when no folder blur is wanted, and nothing was done
*/
private boolean blurFolderBackdrop() {
float amount = folderBlurAmount();
if (!Utilities.ATLEAST_S || amount <= 0f) {
return false;
}
float radius = Math.max(mCurrentBlur,
amount * app.flexera.home.FolderBlur.STRENGTH * mMaxBlurRadius);
if (radius < 1f) {
// Too little to see, and a blur of no radius is not one the system accepts.
mLauncher.getDepthBlurTargets().forEach(target -> target.setRenderEffect(null));
return true;
}
RenderEffect blurEffect = RenderEffect.createBlurEffect(
radius, radius, Shader.TileMode.DECAL);
mLauncher.getDepthBlurTargets().forEach(target -> target.setRenderEffect(blurEffect));
return true;
}
/**
* Returns if cross window blurs are enabled. In other words, whether launcher should use blurs
* style UI or fallback style UI.
@@ -236,7 +309,9 @@ public class BaseDepthControllerImpl implements BaseDepthController {
boolean applyImmediately, boolean skipSimilarBlur) {
float depth = mDepth;
float wallpaperZoom = depth;
if (Flags.enableExpressiveFolderExpansion()) {
// Flexera Launcher: with the zoom switched off this used to be reached by accident
// only; the folder blur now brings it here on purpose, and must not bring the zoom along.
if (Flags.enableExpressiveFolderExpansion() && isDepthEffectEnabled()) {
wallpaperZoom = Math.max(wallpaperZoom, mWallpaperZoomOnly.value);
}
IBinder windowToken = mLauncher.getRootView().getWindowToken();
@@ -261,6 +336,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
} else {
blurAmount = depth;
}
// Flexera Launcher: an open folder blurs what is behind it, whatever the depth is.
float behindFolder = folderBlurAmount() * app.flexera.home.FolderBlur.STRENGTH;
blurAmount = Math.max(blurAmount, behindFolder);
mFolderBlurApplied = behindFolder > 0f;
int previousBlur = mCurrentBlur;
int newBlur = BlurUtils.supportsBlursOnWindows() && mCrossWindowBlursEnabled
@@ -378,6 +457,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
if (!Utilities.ATLEAST_S) {
return false;
}
// Flexera Launcher: an open folder blurs these views in every state, the home screen
// at rest included, which the rules below would leave sharp.
if (blurFolderBackdrop()) {
return true;
}
StateManager<LauncherState, Launcher> stateManager = mLauncher.getStateManager();
LauncherState settledState = stateManager.getState();
@@ -414,6 +498,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
if (!Utilities.ATLEAST_S) {
return;
}
// Flexera Launcher: the blur behind an open folder is not a stuck one, and stays.
if (blurFolderBackdrop()) {
return;
}
mLauncher.getDepthBlurTargets().forEach(target -> {
target.setRenderEffect(null);
target.invalidate();
+11 -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;
@@ -1563,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
@@ -349,6 +349,8 @@ public class DeviceProfile {
private float workspacePaddingHorizontalFactor;
private float workspacePaddingVerticalFactor;
private float widgetPaddingFactor;
// Flexera Launcher: widgets lined up with the icons (app.flexera.widget.FlexeraWidgets).
public boolean alignWidgetsWithIcons;
private float drawerPaddingTopFactor;
private PreferenceManager2 preferenceManager2 = null;
@@ -373,6 +375,8 @@ public class DeviceProfile {
.firstCached(preferenceManager2.getWorkspacePaddingVerticalFactor()), 0f, 2f);
widgetPaddingFactor = Utilities.boundToRange(PreferenceCacheExtensionsKt
.firstCached(preferenceManager2.getWidgetPaddingFactor()), 0f, 2f);
alignWidgetsWithIcons = PreferenceCacheExtensionsKt
.firstCached(preferenceManager2.getAlignWidgetsWithIcons());
drawerPaddingTopFactor = Utilities.boundToRange(PreferenceCacheExtensionsKt
.firstCached(preferenceManager2.getDrawerPaddingTopFactor()), 1f, 2f);
this.inv = inv;
+9 -1
View File
@@ -185,8 +185,16 @@ public class Hotseat extends FrameLayout implements Insettable {
// Create an initial page so alpha/translation channels have a target during construction.
mPagedView.resetPages(false, null, mActivity.getDeviceProfile());
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer, ALPHA_CHANNEL_CHANNELS_COUNT);
// Flexera Launcher: one more channel than upstream, which hides the icons of a dock
// that is switched off. Such a dock has no height, but its icons were still drawn,
// just below the screen edge, and the zoom-out behind an open folder brought their
// tops into view.
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer,
ALPHA_CHANNEL_CHANNELS_COUNT + 1);
mIconsAlphaChannels.setUpdateVisibility(true);
if (!hotseatEnabled) {
mIconsAlphaChannels.get(ALPHA_CHANNEL_CHANNELS_COUNT).setValue(0f);
}
if (mQsb instanceof Reorderable qsbReorderable) {
mQsbTranslationX = qsbReorderable.getTranslateDelegate()
.getTranslationX(MultiTranslateDelegate.INDEX_NAV_BAR_ANIM);
@@ -150,7 +150,11 @@ 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 launcher's own widgets take their cells whole;
// the others are lined up with the icons.
app.flexera.widget.FlexeraWidgets.cellInset(child, profile,
mCellWidth, mCellHeight));
} else {
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
mBorderSpace);
@@ -175,7 +179,11 @@ 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 launcher's own widgets take their cells whole;
// the others are lined up with the icons.
app.flexera.widget.FlexeraWidgets.cellInset(child, dp,
mCellWidth, mCellHeight));
} else if (isChildQsb(child)) {
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
mBorderSpace);
+15 -2
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);
@@ -3290,7 +3300,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET) {
DeviceProfile profile = mLauncher.getDeviceProfile();
if (finalView instanceof NavigableAppWidgetHostView) {
Rect widgetPadding = profile.widgetPadding;
// Flexera Launcher: the room this widget keeps in its cells.
Point cell = profile.getCellSize();
Rect widgetPadding = app.flexera.widget.FlexeraWidgets.cellInset(
finalView, profile, cell.x, cell.y);
r.left -= widgetPadding.left;
r.right += widgetPadding.right;
r.top -= widgetPadding.top;
@@ -81,6 +81,8 @@ class FolderAnimationSpringBuilderManager(
val launcher = launcherDelegate.launcher ?: return
val scrim = launcher.scrimView
launcherDelegate.launcher?.depthController?.folderZoom?.value = 0f
// Flexera Launcher: the blur behind the folder goes with it.
launcherDelegate.launcher?.depthController?.folderBlur?.value = 0f
if (!launcher.isInState(LauncherState.ALL_APPS)) {
scrim.alpha = 1f
scrim.setBackgroundColor(Color.TRANSPARENT)
@@ -57,6 +57,8 @@ class FolderScrimAnimationListener(
// Folder.closeComplete's restore path, leaving workspace/hotseat visually dimmed/scaled.
SCALE_PROPERTY.set(launcher.workspace, 1f)
SCALE_PROPERTY.set(launcher.hotseat, 1f)
// Flexera Launcher: and the blur behind the folder, for the same reason.
launcher.depthController?.folderBlur?.value = 0f
if (Utilities.ATLEAST_S && launcher.stateManager.state.getDepth(launcher) == 0f) {
launcher.depthBlurTargets.forEach { it.setRenderEffect(null) }
}
@@ -91,6 +91,12 @@ class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) {
launcherDelegate.launcher?.depthController?.folderZoom?.let {
addWallpaperZoomAnimator(folder.context, animatorSet, folderAnimData.isOpening, it)
}
addBackdropBlurAnimator(
folder.context,
animatorSet,
folderAnimData.isOpening,
launcherDelegate,
)
iconAnimData.forEach { addContentIconAnimators(folder.context, animatorSet, it) }
return FolderSpringAnimatorSet(animatorSet)
}
@@ -258,6 +264,39 @@ class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) {
)
}
/**
* Flexera Launcher: blurs the home screen behind the folder as it opens, and clears it
* again as it closes, in step with the dimming. Closing always plays, also when the
* setting has been switched off since the folder was opened.
*/
private fun addBackdropBlurAnimator(
context: Context,
animatorSet: AnimatorSet,
isOpening: Boolean,
launcherDelegate: LauncherDelegate,
) {
val launcher = launcherDelegate.launcher ?: return
// As with the dimming: a folder in the app list leaves the list as it is.
if (launcher.isInState(LauncherState.ALL_APPS)) return
val blur = launcher.depthController?.folderBlur ?: return
val wanted =
if (isOpening) app.flexera.home.FolderBlur.isOn(context) else blur.value > 0f
if (!wanted) return
playSpringAnimation(
context = context,
animatorSet = animatorSet,
isOpening = isOpening,
startDelay = 0,
stiffness = STIFFNESS_LAUNCHER_SCRIM,
damping = DAMPING_LAUNCHER_SCRIM,
startValue = 0f,
endValue = 1f,
minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA,
property = MULTI_PROPERTY_VALUE,
view = blur,
)
}
private fun addAlphaAndColorAnimators(
folder: Folder,
animatorSet: AnimatorSet,
@@ -40,4 +40,16 @@ interface BaseDepthController {
* Property for folder expansion wallpaper zoom.
*/
val folderZoom: MultiPropertyFactory<*>.MultiProperty?
/**
* Flexera Launcher: property for the blur of the home screen behind an open folder, from 0
* (none) to 1 (as strong as it gets).
*/
val folderBlur: MultiPropertyFactory<*>.MultiProperty?
/**
* Flexera Launcher: property for the blur of the home screen while quick launch's page
* slides in over it, from 0 (none) to 1 (as strong as it gets).
*/
val quickLaunchBlur: MultiPropertyFactory<*>.MultiProperty?
}
@@ -35,6 +35,8 @@ public class SystemUiController {
public static final int UI_STATE_WIDGET_BOTTOM_SHEET = 2;
public static final int UI_STATE_FULLSCREEN_TASK = 3;
public static final int UI_STATE_ALL_APPS = 4;
// Flexera Launcher: quick launch's black page (app.flexera.home.QuickLaunchPage).
public static final int UI_STATE_QUICK_LAUNCH_PAGE = 5;
public static final int FLAG_LIGHT_NAV = 1 << 0;
public static final int FLAG_DARK_NAV = 1 << 1;
@@ -54,7 +56,7 @@ public class SystemUiController {
public @interface SystemUiControllerFlags {}
private final View mView;
private final int[] mStates = new int[5];
private final int[] mStates = new int[6];
public SystemUiController(View view) {
mView = view;
@@ -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
// (app.flexera.widget.FlexeraWidgets); 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 widgets (pictures, clock) are drawn by a view of
// the launcher, not by views Android sends over; nothing more is loaded for them.
mFlexeraOwnView = app.flexera.widget.FlexeraWidgets.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) {
@@ -61,7 +61,8 @@ public final class WidgetSizes {
final int vBorderSpacing = (spanY - 1) * profile.cellLayoutBorderSpacePx.y;
Point cellSize = profile.getCellSize();
Rect padding = profile.widgetPadding;
// Flexera Launcher: the size the widget really gets, lined up with the icons.
Rect padding = app.flexera.widget.FlexeraWidgets.appWidgetPadding(profile);
return new Size(
(spanX * cellSize.x) + hBorderSpacing - padding.left - padding.right,