Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9026a8ec7a | ||
|
|
f5d7cf07ed | ||
|
|
0dbb146c75 | ||
|
|
900071ccac | ||
|
|
0fbefd2ca6 | ||
|
|
058fecd27f | ||
|
|
6c68cb1369 | ||
|
|
f1fa44b086 | ||
|
|
c93bb1df9c | ||
|
|
bfb70cab63 | ||
|
|
4746d194f4 | ||
|
|
12ec415e48 | ||
|
|
89fa6ef6ca | ||
|
|
d273543070 | ||
|
|
c8da9f301c | ||
|
|
77278a11e0 | ||
|
|
81c5c8914a | ||
|
|
3b7388c473 | ||
|
|
4f687957fb | ||
|
|
b79579a8dd | ||
|
|
516c4efc56 | ||
|
|
8e6b737894 | ||
|
|
31ab0d52e1 | ||
|
|
04e78205dd | ||
|
|
cef6929a41 | ||
|
|
fba65e091f | ||
|
|
b696a376b2 | ||
|
|
bfc2a0ea36 | ||
|
|
fd1c8b195e | ||
|
|
5c4a6c346d | ||
|
|
d5a62cc6f5 | ||
|
|
8cfa2fdc84 | ||
|
|
d0d1c031d0 | ||
|
|
494364ffa2 | ||
|
|
80a56c01c9 | ||
|
|
043e1e0c4a | ||
|
|
d82d488189 | ||
|
|
8894271daa | ||
|
|
3cb262617c | ||
|
|
0959f36587 | ||
|
|
8aba905d9c | ||
|
|
60d00afcfd | ||
|
|
74becdd355 | ||
|
|
4771d2928a | ||
|
|
1f9116d666 | ||
|
|
4e48615b5a |
No files matched your search
@@ -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.
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 & 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 to 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>–</b></div>
|
||||
<div class="race__time"><span>Round 2<i>Flexera's list and wave alphabet</i></span><b data-race-t2>–</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 → Apps → Default apps → 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 → Apps → Default apps → 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>© 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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -494,6 +494,8 @@ dependencies {
|
||||
implementation libs.kdrag0n.colorkt
|
||||
implementation libs.coil.compose
|
||||
implementation libs.xdrop.fuzzywuzzy
|
||||
// Flexera Launcher: encodes the QR code on the About screen. Nothing else of it is used.
|
||||
implementation libs.zxing.core
|
||||
|
||||
implementation libs.bundles.opto
|
||||
implementation libs.androidx.datastore.preferences
|
||||
|
||||
@@ -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>
|
||||
@@ -170,7 +170,10 @@ if ($SkipBuild) {
|
||||
Get-ChildItem (Join-Path $Root 'build\kotlin') -Directory -Filter "compile${Variant}Kotlin*" -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force
|
||||
$env:JAVA_HOME = $JavaHome
|
||||
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain"
|
||||
# "2>&1" inside cmd: Gradle prints compiler warnings on its error stream. If this script's
|
||||
# output is piped or sent to a file, PowerShell would otherwise take the first such line
|
||||
# for an error and stop the build. Only the exit code says whether the build failed.
|
||||
cmd /c ".\gradlew.bat assemble$Variant --no-build-cache --console=plain 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) { throw "The build failed." }
|
||||
}
|
||||
if (-not (Test-Path $apk)) { throw "Expected the APK at $apk, but it is not there." }
|
||||
@@ -208,6 +211,13 @@ if ($NoSmokeTest) {
|
||||
if ($LASTEXITCODE -ne 0 -or -not ($install -match 'Success')) {
|
||||
throw "Could not install the APK on $emulator. If an older build signed with another key is installed there, uninstall it first: adb uninstall $AppId"
|
||||
}
|
||||
# When this app is the emulator's home app, Android restarts it the moment the old copy is
|
||||
# gone, and that first start can die inside Android itself while the files are still being
|
||||
# swapped (a NullPointerException in ActivityThread.handleBindApplication, with no line of
|
||||
# this app in it). That is not the new build crashing: let the swap settle, then start
|
||||
# from a clean slate and judge what happens from there.
|
||||
Start-Sleep -Seconds 6
|
||||
& $Adb -s $emulator shell am force-stop $AppId
|
||||
& $Adb -s $emulator logcat -b crash -c
|
||||
& $Adb -s $emulator shell am start -n $HomeActivity | Out-Null
|
||||
Write-Host "Installed on $emulator and started; watching for $SmokeTestSeconds seconds..."
|
||||
@@ -241,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>
|
||||
@@ -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>
|
||||
@@ -1,11 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Drawer list (PRD AD-2, AD-3). Rows are sized for thumb taps; 48dp is Android's minimum target. -->
|
||||
<dimen name="flexera_drawer_row_height">64dp</dimen>
|
||||
<!-- Smaller than a home-screen icon, so rows have air between them. -->
|
||||
<dimen name="flexera_drawer_row_icon_size">46dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_gap">20dp</dimen>
|
||||
<dimen name="flexera_drawer_row_text_size">17sp</dimen>
|
||||
<!-- Drawer list (PRD AD-2, AD-3). A compact list, at the owner's request: more apps within a
|
||||
thumb's reach. The row stays just above 48dp, Android's minimum for a tap target.
|
||||
(First version: 64dp rows, 46dp icons, 17sp names.) -->
|
||||
<dimen name="flexera_drawer_row_height">50dp</dimen>
|
||||
<!-- Well under a home-screen icon, so rows have air between them. -->
|
||||
<dimen name="flexera_drawer_row_icon_size">34dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_gap">16dp</dimen>
|
||||
<dimen name="flexera_drawer_row_text_size">15sp</dimen>
|
||||
<!-- The "comfy" row size the owner can choose instead (Settings, App drawer): a little
|
||||
larger all round, between compact and the first version. -->
|
||||
<dimen name="flexera_drawer_row_height_comfy">58dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_size_comfy">40dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_gap_comfy">18dp</dimen>
|
||||
<dimen name="flexera_drawer_row_text_size_comfy">16sp</dimen>
|
||||
<!-- Row padding on the edge without the wave alphabet. The search bar lines up with it. -->
|
||||
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
|
||||
<!-- Row padding on the wave alphabet's edge, which is the end edge unless the owner moves it
|
||||
@@ -13,29 +21,64 @@
|
||||
<dimen name="flexera_drawer_row_padding_end">56dp</dimen>
|
||||
|
||||
<!-- Wave alphabet (PRD AD-4 to AD-6). -->
|
||||
<!-- How wide a band along the edge starts a scrub when touched. -->
|
||||
<dimen name="flexera_wave_touch_width">40dp</dimen>
|
||||
<!-- Where the letters rest, measured from the edge. -->
|
||||
<dimen name="flexera_wave_edge_inset">16dp</dimen>
|
||||
<!-- How wide a band along the edge starts a scrub when touched in the open drawer. -->
|
||||
<dimen name="flexera_wave_touch_width">60dp</dimen>
|
||||
<!-- The same band on the home screen, where nothing shows it: narrower, so that fewer
|
||||
swipes that only pass near the edge are taken for a scrub (PRD AD-10). -->
|
||||
<dimen name="flexera_wave_home_touch_width">40dp</dimen>
|
||||
<!-- Where the letters rest, measured from the edge. Inward of the strip of edge where
|
||||
Android starts its Back swipe (about 24dp), so a thumb on the letters is not on it.
|
||||
(First version: 16dp, with the whole edge reserved, which left no Back swipe.) -->
|
||||
<dimen name="flexera_wave_edge_inset">30dp</dimen>
|
||||
<dimen name="flexera_wave_letter_size">12sp</dimen>
|
||||
<!-- Letters never sit further apart than this, however few there are. -->
|
||||
<dimen name="flexera_wave_max_spacing">30dp</dimen>
|
||||
<!-- Letters never sit further apart than this, however few there are. Close together, a
|
||||
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
|
||||
the stretch of edge a thumb covers without shifting the grip. (First version: 30dp,
|
||||
which spread the alphabet over the whole edge.) -->
|
||||
<dimen name="flexera_wave_max_spacing">20dp</dimen>
|
||||
<!-- The stretch of edge kept for the alphabet is as tall as 26 letters and sits low: its end
|
||||
this far above the navigation bar (on top of the vertical margin). The letters that
|
||||
exist are centred in it. -->
|
||||
<dimen name="flexera_wave_bottom_offset">40dp</dimen>
|
||||
<dimen name="flexera_wave_vertical_margin">16dp</dimen>
|
||||
<!-- How far before the finger the wave's peak sits, so the thumb never covers the letter. -->
|
||||
<dimen name="flexera_wave_finger_gap">52dp</dimen>
|
||||
<!-- How far beyond the finger the wave's peak sits, so the thumb never covers the selected
|
||||
letter. A thumb hides about 11 mm (70dp) to each side of the point it touches; the
|
||||
letter's centre has to clear that by its own half width and a margin. At 52dp, the
|
||||
first value, the letter sat under the thumb. -->
|
||||
<dimen name="flexera_wave_finger_gap">104dp</dimen>
|
||||
<!-- The smallest bulge, used when the finger is right on the edge. -->
|
||||
<dimen name="flexera_wave_min_reach">60dp</dimen>
|
||||
<!-- How far up and down the edge the bulge spreads. -->
|
||||
<dimen name="flexera_wave_width">56dp</dimen>
|
||||
<dimen name="flexera_wave_min_reach">96dp</dimen>
|
||||
<!-- How far up and down the edge the bulge spreads, at the least: wide, so that about five
|
||||
letters on each side of the finger ride the wave and curve around the thumb, as in
|
||||
Niagara Launcher. A taller wave is wider (WaveAlphabetView.WAVE_WIDTH_SHARE_OF_REACH). -->
|
||||
<dimen name="flexera_wave_width">104dp</dimen>
|
||||
<!-- The wave's peak never comes closer than this to the opposite edge of the screen. It
|
||||
leaves room for the bubble beyond the peak. -->
|
||||
<dimen name="flexera_wave_far_margin">66dp</dimen>
|
||||
<!-- The bubble with the selected letter: its size, how far beyond the wave's peak its
|
||||
centre sits, and how close it may come to a screen edge. -->
|
||||
<dimen name="flexera_wave_bubble_radius">20dp</dimen>
|
||||
<dimen name="flexera_wave_bubble_gap">38dp</dimen>
|
||||
<dimen name="flexera_wave_bubble_text_size">22sp</dimen>
|
||||
<dimen name="flexera_wave_bubble_edge_margin">6dp</dimen>
|
||||
<dimen name="flexera_wave_bubble_shadow_radius">6dp</dimen>
|
||||
|
||||
<!-- Swipe up and hold (PRD AD-17): a pause only counts once the swipe has travelled this far. -->
|
||||
<dimen name="flexera_hold_min_swipe">72dp</dimen>
|
||||
<!-- Swipe up and hold (PRD AD-17): a pause only counts once the swipe has travelled this far.
|
||||
Enough to tell a hold from a hesitant start, short enough that a small swipe counts. -->
|
||||
<dimen name="flexera_hold_min_swipe">32dp</dimen>
|
||||
|
||||
<!-- Soft shadow behind white drawer text, for readability over light wallpaper. -->
|
||||
<dimen name="flexera_drawer_text_shadow_radius">3dp</dimen>
|
||||
<dimen name="flexera_drawer_text_shadow_dy">1dp</dimen>
|
||||
|
||||
<dimen name="flexera_drawer_header_height">44dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom">6dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size">14sp</dimen>
|
||||
<!-- Picture widget: the words on a widget that has no pictures, and the room beside them. -->
|
||||
<dimen name="flexera_picture_widget_hint_text_size">13sp</dimen>
|
||||
<dimen name="flexera_picture_widget_hint_margin">10dp</dimen>
|
||||
|
||||
<dimen name="flexera_drawer_header_height">34dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom">4dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size">12sp</dimen>
|
||||
<dimen name="flexera_drawer_header_height_comfy">38dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom_comfy">5dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size_comfy">13sp</dimen>
|
||||
</resources>
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources>
|
||||
<!-- The wave alphabet view inside the drawer (app.flexera.drawer.WaveAlphabetView). -->
|
||||
<item name="flexera_wave_alphabet" type="id" />
|
||||
<!-- View tag that marks an app row of the drawer list (FlexeraDrawer.styleAppRow). -->
|
||||
<item name="flexera_drawer_row" type="id" />
|
||||
<!-- View tag on a drawer row: set while the row shows a notification preview under the app name. -->
|
||||
<item name="flexera_notification_preview" type="id" />
|
||||
</resources>
|
||||
@@ -27,9 +27,49 @@
|
||||
<string name="flexera_about_built_on_title">Lawnchair</string>
|
||||
<string name="flexera_about_built_on_body">Flexera Launcher is a modified version of Lawnchair, the open-source launcher made by the Lawnchair team. Lawnchair is itself based on Launcher3 from the Android Open Source Project.</string>
|
||||
<string name="flexera_about_lawnchair_source">Lawnchair source code</string>
|
||||
<!-- Import of a Smart Launcher backup (app.flexera.backup) -->
|
||||
<string name="flexera_import_smart_launcher">Import from Smart Launcher</string>
|
||||
<string name="flexera_import_smart_launcher_description">Home screens, folders and widgets from a Smart Launcher backup file (.slbk)</string>
|
||||
<string name="flexera_import_found">In this backup</string>
|
||||
<string name="flexera_import_screens">Home screens</string>
|
||||
<string name="flexera_import_screens_value">%1$d, on a grid of %2$d by %3$d</string>
|
||||
<string name="flexera_import_apps">Apps on the home screens</string>
|
||||
<string name="flexera_import_folders">Folders</string>
|
||||
<plurals name="flexera_import_folders_value">
|
||||
<item quantity="one">%1$d, holding %2$d app</item>
|
||||
<item quantity="other">%1$d, holding %2$d apps</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_widgets">Widgets</string>
|
||||
<string name="flexera_import_widgets_value">%1$d. Each arrives in its place as “tap to set up”: Android asks you to allow it again, and its own settings start fresh.</string>
|
||||
<string name="flexera_import_photo_widgets">Photo widgets</string>
|
||||
<plurals name="flexera_import_photo_widgets_value">
|
||||
<item quantity="one">%1$d. It becomes Flexera Launcher\'s picture widget and keeps its photo. Tap it once after the import.</item>
|
||||
<item quantity="other">%1$d. They become Flexera Launcher\'s picture widgets and keep their photos. Tap each once after the import.</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_left_out">Left out</string>
|
||||
<string name="flexera_import_own_widgets">Other widgets of Smart Launcher itself</string>
|
||||
<plurals name="flexera_import_own_widgets_value">
|
||||
<item quantity="one">%1$d. It exists only inside Smart Launcher, so its place stays empty.</item>
|
||||
<item quantity="other">%1$d. They exist only inside Smart Launcher, so their places stay empty.</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_skipped">Other items</string>
|
||||
<plurals name="flexera_import_skipped_value">
|
||||
<item quantity="one">%1$d that this launcher cannot show or place</item>
|
||||
<item quantity="other">%1$d that this launcher cannot show or place</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_effect">What importing does</string>
|
||||
<string name="flexera_import_effect_title">Replaces your current home screens</string>
|
||||
<string name="flexera_import_effect_body">The home screens you have now are removed. The grid takes the size it has in the backup and the dock is switched off, because Smart Launcher has none; both can be changed again in Settings. Icons of apps that are not installed on this phone are dropped. Make a backup first if you want to be able to go back.</string>
|
||||
<string name="flexera_import_action">Import</string>
|
||||
<string name="flexera_import_done">Imported</string>
|
||||
<string name="flexera_import_failed">The import failed</string>
|
||||
<string name="flexera_import_unreadable">This is not a Smart Launcher backup that can be read</string>
|
||||
<string name="flexera_about_share">Share</string>
|
||||
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
|
||||
<string name="flexera_about_share_qr_description">QR code that 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>
|
||||
@@ -39,34 +79,127 @@
|
||||
<!-- The drawer's wave alphabet -->
|
||||
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
|
||||
<string name="flexera_alphabet_description">Alphabet</string>
|
||||
<string name="flexera_row_size">Row size</string>
|
||||
<string name="flexera_row_size_compact">Compact</string>
|
||||
<string name="flexera_row_size_comfy">Comfy</string>
|
||||
<string name="flexera_alphabet_search">Search</string>
|
||||
<!-- App drawer settings (app.flexera.ui.FlexeraDrawerSettings) -->
|
||||
<string name="flexera_notification_previews">Notification previews</string>
|
||||
<string name="flexera_notification_previews_description">Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General).</string>
|
||||
<string name="flexera_alphabet_heading">Alphabet</string>
|
||||
<string name="flexera_alphabet_on_left">Alphabet on the left edge</string>
|
||||
<string name="flexera_alphabet_on_left_description">For left-handed use. The letters normally sit on the right edge.</string>
|
||||
<string name="flexera_scrub_from_home">Scrub from the home screen</string>
|
||||
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
|
||||
|
||||
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
|
||||
<string name="flexera_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_check_failed">Couldn\'t reach the update server. Check your connection, then tap to try again.</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 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" />
|
||||
@@ -0,0 +1,692 @@
|
||||
package app.flexera.backup
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import app.flexera.widget.PictureWidgetProvider
|
||||
import app.flexera.widget.PictureWidgetStore
|
||||
import app.lawnchair.DeviceProfileOverrides
|
||||
import app.lawnchair.backup.LawnchairBackup
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import com.android.launcher3.GridType
|
||||
import com.android.launcher3.InvariantDeviceProfile
|
||||
import com.android.launcher3.LauncherSettings.Favorites
|
||||
import com.android.launcher3.model.DatabaseHelper
|
||||
import com.android.launcher3.model.DeviceGridState
|
||||
import com.android.launcher3.model.ModelDbController
|
||||
import com.android.launcher3.pm.UserCache
|
||||
import com.android.launcher3.provider.RestoreDbTask
|
||||
import com.android.launcher3.shortcuts.ShortcutKey
|
||||
import com.android.launcher3.shortcuts.ShortcutRequest
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URISyntaxException
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Imports the home screens of a Smart Launcher backup: icons, folders and widgets, each in the
|
||||
* place it had, on a grid of the same size and, like Smart Launcher, without a dock.
|
||||
*
|
||||
* A backup (a .slbk file) is a zip of Smart Launcher's own files. Two of them matter here:
|
||||
* - `databases/ginlemon.flower.db`, a SQLite database. Its table `HomeItem` has one row per
|
||||
* thing on a home screen (screen, cell, size) that points either at a `Launchable`, which
|
||||
* `Action` describes with an ordinary Android intent, or at a `Widget` with its provider.
|
||||
* A folder is a launchable too; its members are the launchables that name it as their parent.
|
||||
* - the preferences file under `shared_prefs/`, where `launcher_profile_#0` holds the grid.
|
||||
*
|
||||
* The format is not documented. This follows one real backup, of Smart Launcher's data
|
||||
* version 64, and refuses a file in which it does not find these tables.
|
||||
*
|
||||
* What cannot come along is said to the owner before the import ([Info]): Smart Launcher's own
|
||||
* widgets, which exist only inside Smart Launcher, and items of other profiles. A widget that
|
||||
* does come along arrives as "tap to set up": Android has every launcher ask for its widgets
|
||||
* itself, and keeps a widget's settings with the launcher that made it.
|
||||
*
|
||||
* The layout is handed to Launcher3 the way Lawnchair's Nova import hands over its own
|
||||
* (see [app.lawnchair.backup.NovaBackupConverter]): as a database of Launcher3's own shape,
|
||||
* which Launcher3's restore code then takes in.
|
||||
*/
|
||||
class SmartLauncherBackupConverter(
|
||||
private val context: Context,
|
||||
private val uri: Uri,
|
||||
) {
|
||||
|
||||
/** What a backup holds, for the owner to see before agreeing to the import. */
|
||||
data class Info(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
val screens: Int,
|
||||
val apps: Int,
|
||||
val folders: Int,
|
||||
val appsInFolders: Int,
|
||||
val widgets: Int,
|
||||
/** Smart Launcher's photo widgets, which become the launcher's own picture widget. */
|
||||
val photoWidgets: Int,
|
||||
/** Smart Launcher's other widgets of its own, which stay behind. */
|
||||
val ownWidgets: Int,
|
||||
/** Other things that stay behind: no usable target, another profile, or no free cells. */
|
||||
val skipped: Int,
|
||||
)
|
||||
|
||||
/** Where a thing sits on the home screens, in whole cells. */
|
||||
private data class Place(val screen: Int, val x: Int, val y: Int, val width: Int, val height: Int)
|
||||
|
||||
/** An icon: an app, or a shortcut into an app ([shortcutId] with [packageName]). */
|
||||
private data class Icon(
|
||||
val title: String?,
|
||||
val intentUri: String,
|
||||
val packageName: String?,
|
||||
val shortcutId: String?,
|
||||
)
|
||||
|
||||
private sealed interface HomeThing {
|
||||
val place: Place
|
||||
}
|
||||
|
||||
private data class HomeIcon(override val place: Place, val icon: Icon) : HomeThing
|
||||
private data class HomeFolder(override val place: Place, val title: String?, val icons: List<Icon>) : HomeThing
|
||||
private data class HomeWidget(override val place: Place, val provider: String) : HomeThing
|
||||
|
||||
/** One of Smart Launcher's photo widgets, with the unpacked files of its pictures. */
|
||||
private data class HomePicture(override val place: Place, val photos: List<File>) : HomeThing
|
||||
|
||||
private data class Layout(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
val showLabels: Boolean?,
|
||||
val things: List<HomeThing>,
|
||||
val ownWidgets: Int,
|
||||
val skipped: Int,
|
||||
) {
|
||||
val info: Info
|
||||
get() = Info(
|
||||
columns = columns,
|
||||
rows = rows,
|
||||
screens = things.map { it.place.screen }.distinct().size,
|
||||
apps = things.count { it is HomeIcon },
|
||||
folders = things.count { it is HomeFolder },
|
||||
appsInFolders = things.sumOf { (it as? HomeFolder)?.icons?.size ?: 0 },
|
||||
widgets = things.count { it is HomeWidget },
|
||||
photoWidgets = things.count { it is HomePicture },
|
||||
ownWidgets = ownWidgets,
|
||||
skipped = skipped,
|
||||
)
|
||||
}
|
||||
|
||||
/** Reads the backup and says what is in it. Throws if it is not a backup this can read. */
|
||||
suspend fun parseInfo(): Info = withContext(Dispatchers.IO) {
|
||||
withUnpackedBackup { dir -> readLayout(dir).info }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the home screens with the backup's. The launcher has to be restarted afterwards
|
||||
* to show them.
|
||||
*/
|
||||
suspend fun convertAndRestore() {
|
||||
val layout = withContext(Dispatchers.IO) {
|
||||
// The backup stays unpacked to the end: the photo widgets' pictures are read from it.
|
||||
withUnpackedBackup { dir -> readLayout(dir).also(::restore) }
|
||||
}
|
||||
|
||||
// Settings that make the result match the source: no dock, because Smart Launcher has
|
||||
// none, and labels as they were. Last, so that a failed import changes nothing.
|
||||
val prefs2 = PreferenceManager2.getInstance(context)
|
||||
prefs2.isHotseatEnabled.set(false)
|
||||
layout.showLabels?.let { prefs2.showIconLabelsOnHomeScreen.set(it) }
|
||||
}
|
||||
|
||||
/** Puts the layout into the launcher's database and keeps the photo widgets' pictures. */
|
||||
private fun restore(layout: Layout) {
|
||||
val tempDir = newTempDir()
|
||||
try {
|
||||
val staged = File(tempDir, STAGED_DB)
|
||||
val shortcuts = writeLauncherDb(layout, staged)
|
||||
applyGrid(layout)
|
||||
|
||||
val restored = context.getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME)
|
||||
restored.parentFile?.mkdirs()
|
||||
staged.copyTo(restored, overwrite = true)
|
||||
// Opening the controller moves the restored file into place; the restore task
|
||||
// then marks every item as freshly restored, which is what makes Launcher3 ask
|
||||
// for the widgets anew and drop icons of apps that are not installed.
|
||||
check(RestoreDbTask.performRestore(context, ModelDbController(context))) {
|
||||
"Launcher3 did not accept the converted layout"
|
||||
}
|
||||
pinShortcuts(shortcuts)
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
keepPictures(layout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the pictures of the imported photo widgets where the picture widget finds them. Each
|
||||
* widget arrives as "tap to set up" like every imported widget; the tap then gives it these
|
||||
* pictures in place of asking for some (PictureWidgetConfigActivity).
|
||||
*/
|
||||
private fun keepPictures(layout: Layout) {
|
||||
PictureWidgetStore.clearImports(context)
|
||||
for (thing in layout.things) {
|
||||
if (thing is HomePicture && thing.photos.isNotEmpty()) {
|
||||
PictureWidgetStore.stageImport(
|
||||
context,
|
||||
thing.place.screen,
|
||||
thing.place.x,
|
||||
thing.place.y,
|
||||
thing.photos.map(Uri::fromFile),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reading the backup ------------------------------------------------------------------
|
||||
|
||||
private fun <T> withUnpackedBackup(block: (File) -> T): T {
|
||||
val dir = newTempDir()
|
||||
try {
|
||||
unpack(dir)
|
||||
return block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newTempDir(): File =
|
||||
File(context.cacheDir, "$TEMP_DIR_PREFIX${UUID.randomUUID()}").apply { mkdirs() }
|
||||
|
||||
/**
|
||||
* Unpacks the database, the preference files and the pictures of the photo widgets, and
|
||||
* nothing else, into [dir]. Entry names come from the file and are not trusted: only the
|
||||
* known places are accepted, each file lands under a name made here, and a file of
|
||||
* unreasonable size ends the import.
|
||||
*/
|
||||
private fun unpack(dir: File) {
|
||||
val descriptor = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
?: error("Cannot open the backup file")
|
||||
descriptor.use {
|
||||
ZipInputStream(FileInputStream(it.fileDescriptor)).use { zip ->
|
||||
var photos = 0
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name
|
||||
val photo = SL_PHOTO_ENTRY.matchEntire(name)
|
||||
val target = when {
|
||||
entry.isDirectory -> null
|
||||
name == SL_DB_ENTRY -> File(dir, SL_DB)
|
||||
name.startsWith(SL_PREFS_DIR) && name.endsWith(".xml") ->
|
||||
File(dir, PREFS_PREFIX + File(name).name)
|
||||
// A photo widget's picture, kept under the number of its widget.
|
||||
photo != null && photos < MAX_PHOTOS ->
|
||||
File(dir, photoName(photo.groupValues[1], photos++))
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
require(target.canonicalPath.startsWith(dir.canonicalPath + File.separator)) {
|
||||
"Backup entry outside the import folder: $name"
|
||||
}
|
||||
FileOutputStream(target).use { out ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
require(total <= MAX_ENTRY_BYTES) { "Backup entry too large: $name" }
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLayout(dir: File): Layout {
|
||||
val dbFile = File(dir, SL_DB)
|
||||
require(dbFile.exists()) { "No Smart Launcher database in this file" }
|
||||
val grid = readGrid(dir)
|
||||
|
||||
SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
for (table in listOf(T_HOME, T_LAUNCHABLE, T_ACTION, T_WIDGET)) {
|
||||
require(tableExists(db, table)) { "Smart Launcher database without table $table" }
|
||||
}
|
||||
return readHomeScreens(db, grid, dir)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Grid(val columns: Int, val rows: Int, val showLabels: Boolean?)
|
||||
|
||||
/** The home grid from the preferences, or null where a backup does not state it. */
|
||||
private fun readGrid(dir: File): Grid? {
|
||||
val files = dir.listFiles { file -> file.name.startsWith(PREFS_PREFIX) } ?: return null
|
||||
for (file in files) {
|
||||
try {
|
||||
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
|
||||
val strings = document.documentElement.getElementsByTagName("string")
|
||||
for (i in 0 until strings.length) {
|
||||
val node = strings.item(i)
|
||||
val name = node.attributes?.getNamedItem("name")?.nodeValue ?: continue
|
||||
if (!name.startsWith(SL_PROFILE_KEY_PREFIX)) continue
|
||||
val settings = JSONObject(node.textContent).optJSONObject("gridUserSettings") ?: continue
|
||||
val columns = settings.optInt("columns", 0)
|
||||
val rows = settings.optInt("rows", 0)
|
||||
if (columns in 1..MAX_GRID && rows in 1..MAX_GRID) {
|
||||
val labels = if (settings.has("hasLabel")) settings.optBoolean("hasLabel") else null
|
||||
return Grid(columns, rows, labels)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unreadable preference file in backup: ${file.name}", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?, dir: File): Layout {
|
||||
data class Row(
|
||||
val launchable: Long?,
|
||||
val widget: Long?,
|
||||
val screen: Int,
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
// Smart Launcher can keep more than one arrangement ("configId"); the first is the one
|
||||
// in use on a phone held upright.
|
||||
val rows = ArrayList<Row>()
|
||||
db.rawQuery(
|
||||
"SELECT idLaunchable, idWidget, screen, cellX, cellY, spanX, spanY FROM $T_HOME " +
|
||||
"WHERE configId = (SELECT MIN(configId) FROM $T_HOME) ORDER BY zIndex, id",
|
||||
null,
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
rows.add(
|
||||
Row(
|
||||
launchable = if (c.isNull(0)) null else c.getLong(0),
|
||||
widget = if (c.isNull(1)) null else c.getLong(1),
|
||||
screen = c.getInt(2),
|
||||
x = c.getDouble(3).roundToInt(),
|
||||
y = c.getDouble(4).roundToInt(),
|
||||
width = c.getDouble(5).roundToInt().coerceAtLeast(1),
|
||||
height = c.getDouble(6).roundToInt().coerceAtLeast(1),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val columns = grid?.columns ?: max(MIN_GRID, rows.maxOfOrNull { it.x + it.width } ?: 0)
|
||||
val gridRows = grid?.rows ?: max(MIN_GRID, rows.maxOfOrNull { it.y + it.height } ?: 0)
|
||||
// Launcher3 numbers its screens from 0 without gaps, left to right.
|
||||
val screenIndex = rows.map { it.screen }.distinct().sorted().withIndex().associate { it.value to it.index }
|
||||
val taken = HashSet<Triple<Int, Int, Int>>()
|
||||
|
||||
val things = ArrayList<HomeThing>()
|
||||
var ownWidgets = 0
|
||||
var skipped = 0
|
||||
for (row in rows) {
|
||||
val screen = screenIndex.getValue(row.screen)
|
||||
val place = Place(screen, row.x, row.y, row.width, row.height)
|
||||
val cells = cellsOf(place)
|
||||
// Launcher3 keeps one thing per cell and nothing outside the grid.
|
||||
val fits = row.x >= 0 && row.y >= 0 && row.x + row.width <= columns &&
|
||||
row.y + row.height <= gridRows && cells.none { it in taken }
|
||||
|
||||
val read: HomeThing? = when {
|
||||
row.widget != null -> readWidget(db, row.widget, place, dir)
|
||||
row.launchable != null -> readLaunchable(db, row.launchable, place)
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
// The only widgets with no counterpart here are Smart Launcher's own.
|
||||
read == null && row.widget != null -> ownWidgets++
|
||||
read == null || !fits -> skipped++
|
||||
else -> {
|
||||
things.add(read)
|
||||
taken.addAll(cells)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Layout(columns, gridRows, grid?.showLabels, things, ownWidgets, skipped)
|
||||
}
|
||||
|
||||
private fun cellsOf(place: Place): List<Triple<Int, Int, Int>> = buildList {
|
||||
for (x in place.x until place.x + place.width) {
|
||||
for (y in place.y until place.y + place.height) {
|
||||
add(Triple(place.screen, x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a widget of the backup becomes. An ordinary app widget keeps its provider. Smart
|
||||
* Launcher's photo widget becomes the launcher's own picture widget, with the pictures the
|
||||
* backup holds for it. Smart Launcher's other widgets have no counterpart: null.
|
||||
*/
|
||||
private fun readWidget(db: SQLiteDatabase, id: Long, place: Place, dir: File): HomeThing? =
|
||||
db.rawQuery("SELECT type, provider, userId FROM $T_WIDGET WHERE id = ?", arrayOf(id.toString())).use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
null
|
||||
} else {
|
||||
val type = c.getInt(0)
|
||||
val provider = if (c.isNull(1)) null else c.getString(1)
|
||||
val user = if (c.isNull(2)) OWNER_USER else c.getInt(2)
|
||||
val component = provider?.let(ComponentName::unflattenFromString)
|
||||
when {
|
||||
type == SL_WIDGET_TYPE_APP && component != null && user == OWNER_USER ->
|
||||
HomeWidget(place, component.flattenToString())
|
||||
|
||||
type != SL_WIDGET_TYPE_APP && provider?.endsWith(SL_PHOTO_WIDGET_CLASS) == true -> {
|
||||
val prefix = photoName(id.toString(), null)
|
||||
val photos = dir.listFiles { file -> file.name.startsWith(prefix) }
|
||||
HomePicture(place, photos?.sortedBy { it.name }.orEmpty())
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon or folder behind a launchable, or null if it has no target this can use. */
|
||||
private fun readLaunchable(db: SQLiteDatabase, id: Long, place: Place): HomeThing? {
|
||||
val action = readAction(db, id) ?: return null
|
||||
if (action.type != SL_ACTION_TYPE_FOLDER) {
|
||||
return action.toIcon()?.let { HomeIcon(place, it) }
|
||||
}
|
||||
val icons = ArrayList<Icon>()
|
||||
db.rawQuery(
|
||||
"SELECT id FROM $T_LAUNCHABLE WHERE idParentFolderLaunchable = ? ORDER BY position, id",
|
||||
arrayOf(id.toString()),
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
readAction(db, c.getLong(0))?.takeIf { it.type != SL_ACTION_TYPE_FOLDER }?.toIcon()?.let(icons::add)
|
||||
}
|
||||
}
|
||||
// Launcher3 has no use for a folder with nothing in it.
|
||||
return if (icons.isEmpty()) null else HomeFolder(place, action.label, icons)
|
||||
}
|
||||
|
||||
private data class Action(
|
||||
val type: Int,
|
||||
val intentUri: String?,
|
||||
val user: Int,
|
||||
val label: String?,
|
||||
val shortcutId: String?,
|
||||
) {
|
||||
/** Launcher3's form of this action, if it opens an app or one of an app's shortcuts. */
|
||||
fun toIcon(): Icon? {
|
||||
// Icons of a work profile or a second user are left out; -1 means "no user", as on folders.
|
||||
if (user != OWNER_USER && user != NO_USER) return null
|
||||
val intent = try {
|
||||
Intent.parseUri(intentUri ?: return null, 0)
|
||||
} catch (_: URISyntaxException) {
|
||||
return null
|
||||
}
|
||||
val component = intent.component
|
||||
val packageName = component?.packageName ?: intent.`package`
|
||||
return when {
|
||||
!shortcutId.isNullOrEmpty() && packageName != null ->
|
||||
Icon(label, ShortcutKey.makeIntent(shortcutId, packageName).toUri(0), packageName, shortcutId)
|
||||
|
||||
component != null -> Icon(label, launchIntentFor(component).toUri(0), packageName, null)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The first action of a launchable, which is what a tap does. */
|
||||
private fun readAction(db: SQLiteDatabase, launchable: Long): Action? =
|
||||
db.rawQuery(
|
||||
"SELECT type, intentUri, userId, label, deepShortcutId FROM $T_ACTION " +
|
||||
"WHERE idLaunchable = ? ORDER BY actionId LIMIT 1",
|
||||
arrayOf(launchable.toString()),
|
||||
).use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
null
|
||||
} else {
|
||||
Action(
|
||||
type = c.getInt(0),
|
||||
intentUri = if (c.isNull(1)) null else c.getString(1),
|
||||
user = if (c.isNull(2)) OWNER_USER else c.getInt(2),
|
||||
label = if (c.isNull(3)) null else c.getString(3),
|
||||
shortcutId = if (c.isNull(4)) null else c.getString(4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableExists(db: SQLiteDatabase, table: String): Boolean =
|
||||
db.rawQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", arrayOf(table)).use {
|
||||
it.moveToFirst()
|
||||
}
|
||||
|
||||
// ---- Writing Launcher3's layout ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Writes [layout] as a Launcher3 database at [target]. Returns the app shortcuts in it, by
|
||||
* package, which the launcher has to pin before it may show them.
|
||||
*/
|
||||
private fun writeLauncherDb(layout: Layout, target: File): Map<String, Set<String>> {
|
||||
val profileId = UserCache.INSTANCE.get(context).getSerialNumberForUser(Process.myUserHandle())
|
||||
val shortcuts = HashMap<String, MutableSet<String>>()
|
||||
val now = System.currentTimeMillis()
|
||||
var nextId = 1
|
||||
|
||||
SQLiteDatabase.openOrCreateDatabase(target, null).use { db ->
|
||||
db.version = DatabaseHelper.SCHEMA_VERSION
|
||||
Favorites.addTableToDb(db, profileId, false)
|
||||
|
||||
fun insert(
|
||||
container: Int,
|
||||
place: Place?,
|
||||
rank: Int,
|
||||
itemType: Int,
|
||||
title: String? = null,
|
||||
intentUri: String? = null,
|
||||
provider: String? = null,
|
||||
): Int {
|
||||
val id = nextId++
|
||||
db.insertOrThrow(
|
||||
Favorites.TABLE_NAME,
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put(Favorites._ID, id)
|
||||
put(Favorites.TITLE, title)
|
||||
put(Favorites.INTENT, intentUri)
|
||||
put(Favorites.CONTAINER, container)
|
||||
put(Favorites.SCREEN, place?.screen ?: 0)
|
||||
put(Favorites.CELLX, place?.x ?: 0)
|
||||
put(Favorites.CELLY, place?.y ?: 0)
|
||||
put(Favorites.SPANX, place?.width ?: 1)
|
||||
put(Favorites.SPANY, place?.height ?: 1)
|
||||
put(Favorites.ITEM_TYPE, itemType)
|
||||
put(Favorites.APPWIDGET_ID, -1)
|
||||
put(Favorites.APPWIDGET_PROVIDER, provider)
|
||||
put(Favorites.MODIFIED, now)
|
||||
put(Favorites.RESTORED, 0)
|
||||
put(Favorites.PROFILE_ID, profileId)
|
||||
put(Favorites.RANK, rank)
|
||||
put(Favorites.OPTIONS, 0)
|
||||
put(Favorites.APPWIDGET_SOURCE, -1)
|
||||
},
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
fun insertIcon(icon: Icon, container: Int, place: Place?, rank: Int) {
|
||||
val isShortcut = icon.shortcutId != null && icon.packageName != null
|
||||
if (isShortcut) {
|
||||
shortcuts.getOrPut(icon.packageName!!) { HashSet() }.add(icon.shortcutId!!)
|
||||
}
|
||||
insert(
|
||||
container = container,
|
||||
place = place,
|
||||
rank = rank,
|
||||
itemType = if (isShortcut) Favorites.ITEM_TYPE_DEEP_SHORTCUT else Favorites.ITEM_TYPE_APPLICATION,
|
||||
title = icon.title,
|
||||
intentUri = icon.intentUri,
|
||||
)
|
||||
}
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (thing in layout.things) {
|
||||
when (thing) {
|
||||
is HomeIcon -> insertIcon(thing.icon, Favorites.CONTAINER_DESKTOP, thing.place, 0)
|
||||
|
||||
is HomeFolder -> {
|
||||
val folder = insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_FOLDER,
|
||||
title = thing.title,
|
||||
)
|
||||
thing.icons.forEachIndexed { rank, icon -> insertIcon(icon, folder, null, rank) }
|
||||
}
|
||||
|
||||
is HomeWidget -> insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_APPWIDGET,
|
||||
provider = thing.provider,
|
||||
)
|
||||
|
||||
is HomePicture -> insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_APPWIDGET,
|
||||
provider = PictureWidgetProvider.componentName.flattenToString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
return shortcuts
|
||||
}
|
||||
|
||||
/** Makes the home grid the size of the backup's, in every place the launcher keeps it. */
|
||||
private fun applyGrid(layout: Layout) {
|
||||
val prefs = PreferenceManager.getInstance(context)
|
||||
val hotseat = prefs.hotseatColumns.get()
|
||||
val gridInfo = DeviceProfileOverrides.DBGridInfo(
|
||||
numHotseatColumns = hotseat,
|
||||
numRows = layout.rows,
|
||||
numColumns = layout.columns,
|
||||
)
|
||||
val gridState = DeviceGridState(
|
||||
layout.columns,
|
||||
layout.rows,
|
||||
hotseat,
|
||||
InvariantDeviceProfile.TYPE_PHONE,
|
||||
gridInfo.dbFile,
|
||||
GridType.GRID_TYPE_ANY,
|
||||
)
|
||||
gridState.writeToPrefs(context, true)
|
||||
gridState.writeToPrefs(context)
|
||||
InvariantDeviceProfile.INSTANCE.get(context).dbFile = gridInfo.dbFile
|
||||
prefs.sp.edit()
|
||||
.putInt(prefs.workspaceColumns.key, layout.columns)
|
||||
.putInt(prefs.workspaceRows.key, layout.rows)
|
||||
.commit()
|
||||
}
|
||||
|
||||
/** Pins the imported app shortcuts, keeping whatever the launcher has pinned already. */
|
||||
private fun pinShortcuts(shortcuts: Map<String, Set<String>>) {
|
||||
if (shortcuts.isEmpty()) return
|
||||
val launcherApps = context.getSystemService(LauncherApps::class.java) ?: return
|
||||
val user = Process.myUserHandle()
|
||||
for ((packageName, ids) in shortcuts) {
|
||||
val pinned = LinkedHashSet<String>()
|
||||
val current = ShortcutRequest(context, user)
|
||||
.forPackage(packageName)
|
||||
.query(ShortcutRequest.PINNED or LauncherApps.ShortcutQuery.FLAG_GET_KEY_FIELDS_ONLY)
|
||||
if (current.wasSuccess()) current.mapTo(pinned) { it.id }
|
||||
pinned.addAll(ids)
|
||||
try {
|
||||
launcherApps.pinShortcuts(packageName, pinned.toList(), user)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not pin the imported shortcuts of $packageName", e)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "Could not pin the imported shortcuts of $packageName", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SmartLauncherImport"
|
||||
|
||||
const val SL_DB_ENTRY = "databases/ginlemon.flower.db"
|
||||
const val SL_PREFS_DIR = "shared_prefs/"
|
||||
const val SL_PROFILE_KEY_PREFIX = "launcher_profile_"
|
||||
const val T_HOME = "HomeItem"
|
||||
const val T_LAUNCHABLE = "Launchable"
|
||||
const val T_ACTION = "Action"
|
||||
const val T_WIDGET = "Widget"
|
||||
|
||||
/** `Action.type` of a folder; every other type seen opens an app. */
|
||||
const val SL_ACTION_TYPE_FOLDER = 9
|
||||
|
||||
/** `Widget.type` of an ordinary app widget; the other types are Smart Launcher's own. */
|
||||
const val SL_WIDGET_TYPE_APP = 1
|
||||
|
||||
/** How `Widget.provider` ends for Smart Launcher's photo widget, in every edition of it. */
|
||||
const val SL_PHOTO_WIDGET_CLASS = ".PhotoWidget"
|
||||
|
||||
/** Where a backup keeps the pictures of a photo widget: a folder named by `Widget.id`. */
|
||||
val SL_PHOTO_ENTRY = Regex("files/widgetPhoto/(\\d{1,18})/[^/]+")
|
||||
|
||||
/** More pictures than this in one backup are not taken along. */
|
||||
const val MAX_PHOTOS = 100
|
||||
|
||||
/**
|
||||
* The name an unpacked picture gets: its widget's number, then its turn. Without a turn,
|
||||
* the start of the names of all pictures of that widget.
|
||||
*/
|
||||
fun photoName(widget: String, turn: Int?): String = "photo_${widget}_" + (turn?.toString() ?: "")
|
||||
|
||||
/** Smart Launcher's user ids: the phone's owner, and "no user" on folders. */
|
||||
const val OWNER_USER = 0
|
||||
const val NO_USER = -1
|
||||
|
||||
const val TEMP_DIR_PREFIX = "smartlauncher_"
|
||||
const val SL_DB = "smartlauncher.db"
|
||||
const val PREFS_PREFIX = "prefs_"
|
||||
const val STAGED_DB = "workspace.db"
|
||||
|
||||
/** Far above any real backup's database; a larger entry is not a backup. */
|
||||
const val MAX_ENTRY_BYTES = 64L * 1024 * 1024
|
||||
|
||||
const val MIN_GRID = 4
|
||||
const val MAX_GRID = 20
|
||||
|
||||
/** The intent Launcher3 itself stores for an app icon. */
|
||||
fun launchIntentFor(component: ComponentName): Intent =
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(component)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package app.flexera.backup
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import app.flexera.backup.SmartLauncherImportViewModel.Event
|
||||
import app.flexera.backup.SmartLauncherImportViewModel.State
|
||||
import app.lawnchair.ui.preferences.LocalIsExpandedScreen
|
||||
import app.lawnchair.ui.preferences.LocalNavController
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
|
||||
import app.lawnchair.ui.preferences.navigation.ImportSmartLauncherBackup
|
||||
import app.lawnchair.util.BackHandler
|
||||
import app.lawnchair.util.restartLauncher
|
||||
import com.android.launcher3.R
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* The screen between choosing a Smart Launcher backup and importing it. It says what the file
|
||||
* holds, what will not come along and what the import replaces, and waits for the owner's word.
|
||||
*/
|
||||
fun NavGraphBuilder.smartLauncherImportGraph() {
|
||||
composable<ImportSmartLauncherBackup> { entry ->
|
||||
val route: ImportSmartLauncherBackup = entry.toRoute()
|
||||
val backup = remember { String(Base64.getDecoder().decode(route.base64Uri)).toUri() }
|
||||
val viewModel: SmartLauncherImportViewModel = viewModel()
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.open(backup)
|
||||
onDispose { }
|
||||
}
|
||||
SmartLauncherImportScreen(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmartLauncherImportScreen(viewModel: SmartLauncherImportViewModel) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
Event.Imported -> {
|
||||
Toast.makeText(context, R.string.flexera_import_done, Toast.LENGTH_SHORT).show()
|
||||
restartLauncher(context)
|
||||
}
|
||||
|
||||
Event.Failed -> Toast.makeText(context, R.string.flexera_import_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PreferenceLayout(
|
||||
label = stringResource(id = R.string.flexera_import_smart_launcher),
|
||||
backArrowVisible = !LocalIsExpandedScreen.current,
|
||||
) {
|
||||
when (val current = state) {
|
||||
State.Reading -> Box(modifier = Modifier.fillMaxSize()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
State.Unreadable -> {
|
||||
val back = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
LaunchedEffect(Unit) {
|
||||
Toast.makeText(context, R.string.flexera_import_unreadable, Toast.LENGTH_LONG).show()
|
||||
back?.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
is State.Ready -> {
|
||||
// Leaving in the middle of an import would leave the home screens half replaced.
|
||||
if (current.importing) BackHandler {}
|
||||
BackupContents(current.info)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = viewModel::import,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !current.importing,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.flexera_import_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupContents(info: SmartLauncherBackupConverter.Info) {
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_found)) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_screens,
|
||||
stringResource(id = R.string.flexera_import_screens_value, info.screens, info.columns, info.rows),
|
||||
)
|
||||
if (info.apps > 0) InfoRow(R.string.flexera_import_apps, info.apps.toString())
|
||||
if (info.folders > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_folders,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_folders_value,
|
||||
count = info.appsInFolders,
|
||||
info.folders,
|
||||
info.appsInFolders,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (info.widgets > 0) {
|
||||
InfoRow(R.string.flexera_import_widgets, stringResource(id = R.string.flexera_import_widgets_value, info.widgets))
|
||||
}
|
||||
if (info.photoWidgets > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_photo_widgets,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_photo_widgets_value,
|
||||
count = info.photoWidgets,
|
||||
info.photoWidgets,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.ownWidgets > 0 || info.skipped > 0) {
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_left_out)) {
|
||||
if (info.ownWidgets > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_own_widgets,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_own_widgets_value,
|
||||
count = info.ownWidgets,
|
||||
info.ownWidgets,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (info.skipped > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_skipped,
|
||||
pluralStringResource(id = R.plurals.flexera_import_skipped_value, count = info.skipped, info.skipped),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_effect)) {
|
||||
PreferenceTemplate(
|
||||
title = { Text(text = stringResource(id = R.string.flexera_import_effect_title)) },
|
||||
description = { Text(text = stringResource(id = R.string.flexera_import_effect_body)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(labelRes: Int, value: String) {
|
||||
PreferenceTemplate(
|
||||
title = { Text(text = stringResource(id = labelRes)) },
|
||||
description = { Text(text = value) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns an action that lets the owner pick a backup file and then opens the import screen for it. */
|
||||
@Composable
|
||||
fun smartLauncherImportOpener(): () -> Unit {
|
||||
val navController = LocalNavController.current
|
||||
val request = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
|
||||
val base64Uri = Base64.getEncoder().encodeToString(uri.toString().toByteArray())
|
||||
navController.navigate(ImportSmartLauncherBackup(base64Uri))
|
||||
}
|
||||
return {
|
||||
// A .slbk file has no file type Android knows, so every file is offered.
|
||||
request.launch(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("*/*"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app.flexera.backup
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Holds one Smart Launcher import: reads the chosen backup, shows what is in it, and on the
|
||||
* owner's word replaces the home screens with it. Same shape as Lawnchair's view model for a
|
||||
* Nova backup.
|
||||
*/
|
||||
internal class SmartLauncherImportViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
sealed interface Event {
|
||||
data object Imported : Event
|
||||
data object Failed : Event
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
data object Reading : State
|
||||
data object Unreadable : State
|
||||
data class Ready(val info: SmartLauncherBackupConverter.Info, val importing: Boolean = false) : State
|
||||
}
|
||||
|
||||
private var converter: SmartLauncherBackupConverter? = null
|
||||
|
||||
private val mutableState = MutableStateFlow<State>(State.Reading)
|
||||
val state: StateFlow<State> = mutableState.asStateFlow()
|
||||
|
||||
private val eventChannel = Channel<Event>(Channel.BUFFERED)
|
||||
val events = eventChannel.receiveAsFlow()
|
||||
|
||||
fun open(backup: Uri) {
|
||||
if (converter != null) return
|
||||
val converter = SmartLauncherBackupConverter(getApplication(), backup).also { converter = it }
|
||||
viewModelScope.launch {
|
||||
mutableState.value = try {
|
||||
State.Ready(converter.parseInfo())
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Not a Smart Launcher backup this can read", t)
|
||||
State.Unreadable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import() {
|
||||
val ready = mutableState.value as? State.Ready ?: return
|
||||
val converter = converter ?: return
|
||||
if (ready.importing) return
|
||||
viewModelScope.launch {
|
||||
mutableState.value = ready.copy(importing = true)
|
||||
try {
|
||||
converter.convertAndRestore()
|
||||
eventChannel.send(Event.Imported)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Import failed", t)
|
||||
mutableState.value = ready.copy(importing = false)
|
||||
eventChannel.send(Event.Failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SmartLauncherImport"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ import com.android.launcher3.BubbleTextView
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.allapps.ActivityAllAppsContainerView
|
||||
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.android.launcher3.views.BaseDragLayer
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
|
||||
@@ -44,9 +47,54 @@ object FlexeraDrawer {
|
||||
@JvmStatic
|
||||
fun sectionHeader(sectionName: String): AdapterItem = SectionHeaderItem(sectionName)
|
||||
|
||||
/** The sizes the owner can give the list's rows (Settings, App drawer). */
|
||||
enum class RowSize(val key: String) { COMPACT("compact"), COMFY("comfy") }
|
||||
|
||||
/** The row size in use. Changing the setting recreates the launcher. */
|
||||
@JvmStatic
|
||||
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View =
|
||||
inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false).also(::applySidePadding)
|
||||
fun rowSize(context: Context): RowSize {
|
||||
val key = PreferenceManager.getInstance(context).flexeraDrawerRowSize.get()
|
||||
return RowSize.entries.firstOrNull { it.key == key } ?: RowSize.COMPACT
|
||||
}
|
||||
|
||||
/** Picks the dimension of the row size in use. */
|
||||
private fun sized(context: Context, compact: Int, comfy: Int): Int =
|
||||
if (rowSize(context) == RowSize.COMFY) comfy else compact
|
||||
|
||||
/** How tall a letter header is. */
|
||||
@JvmStatic
|
||||
fun headerHeight(context: Context): Int = context.resources.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_header_height, R.dimen.flexera_drawer_header_height_comfy),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View {
|
||||
val header = inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false) as TextView
|
||||
val context = header.context
|
||||
val res = context.resources
|
||||
// The layout carries the compact sizes; the row size in use has the last word.
|
||||
header.layoutParams.height = headerHeight(context)
|
||||
header.setTextSize(
|
||||
TypedValue.COMPLEX_UNIT_PX,
|
||||
res.getDimension(
|
||||
sized(context, R.dimen.flexera_drawer_header_text_size, R.dimen.flexera_drawer_header_text_size_comfy),
|
||||
),
|
||||
)
|
||||
header.setPadding(
|
||||
header.paddingLeft,
|
||||
header.paddingTop,
|
||||
header.paddingRight,
|
||||
res.getDimensionPixelSize(
|
||||
sized(
|
||||
context,
|
||||
R.dimen.flexera_drawer_header_padding_bottom,
|
||||
R.dimen.flexera_drawer_header_padding_bottom_comfy,
|
||||
),
|
||||
),
|
||||
)
|
||||
applySidePadding(header)
|
||||
return header
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the owner has moved the wave alphabet to the left edge (PRD AD-15). Changing the
|
||||
@@ -87,9 +135,20 @@ object FlexeraDrawer {
|
||||
@JvmStatic
|
||||
fun styleAppRow(icon: BubbleTextView) {
|
||||
val res = icon.resources
|
||||
icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height)
|
||||
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
|
||||
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.flexera_drawer_row_text_size))
|
||||
// Other icons share the "all apps" display type (search results, for one); this tag
|
||||
// tells list rows apart from them.
|
||||
icon.setTag(R.id.flexera_drawer_row, true)
|
||||
val context = icon.context
|
||||
icon.layoutParams.height = rowHeight(context)
|
||||
icon.setIconSizeOverride(
|
||||
res.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_row_icon_size, R.dimen.flexera_drawer_row_icon_size_comfy),
|
||||
),
|
||||
)
|
||||
icon.compoundDrawablePadding = res.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_row_icon_gap, R.dimen.flexera_drawer_row_icon_gap_comfy),
|
||||
)
|
||||
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, rowTextSize(context))
|
||||
applySidePadding(icon)
|
||||
// The drawer has no background, so names sit directly on the dimmed wallpaper: white,
|
||||
// with a soft shadow to stay readable over light areas.
|
||||
@@ -105,12 +164,89 @@ object FlexeraDrawer {
|
||||
/** Shadow behind white drawer text. */
|
||||
const val TEXT_SHADOW_COLOR = 0x73000000
|
||||
|
||||
/**
|
||||
* How tall an app row is. The row has to hold the name with a notification preview under
|
||||
* it (PRD AD-11). At the usual font size the set height does; with a large system font
|
||||
* the row grows instead.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun rowHeight(context: Context): Int = max(
|
||||
context.resources.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_row_height, R.dimen.flexera_drawer_row_height_comfy),
|
||||
),
|
||||
(rowTextSize(context) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
|
||||
)
|
||||
|
||||
/** The size of an app name in a row, in pixels. */
|
||||
private fun rowTextSize(context: Context): Float = context.resources.getDimension(
|
||||
sized(context, R.dimen.flexera_drawer_row_text_size, R.dimen.flexera_drawer_row_text_size_comfy),
|
||||
)
|
||||
|
||||
/** The least a row's height may be, in name text sizes: two lines of text and some air. */
|
||||
private const val ROW_HEIGHT_IN_TEXT_SIZES = 2.4f
|
||||
|
||||
/**
|
||||
* What lies between the open drawer and the blurred wallpaper: black at 78%, whatever the
|
||||
* theme and the wallpaper. Lawnchair's backdrop is a grey at 40%, which over a light
|
||||
* wallpaper leaves a light grey behind the drawer's white names.
|
||||
*/
|
||||
const val BACKDROP_COLOR = 0xC7000000.toInt()
|
||||
|
||||
/**
|
||||
* Lawnchair fades the search field's pill out while the owner types, leaving the text on the
|
||||
* drawer's panel. This drawer has no panel, so the pill stays.
|
||||
*/
|
||||
const val KEEP_SEARCH_FIELD_BACKGROUND = true
|
||||
|
||||
/**
|
||||
* Moves the search field up with the list, so that it scrolls away like the list's first
|
||||
* row and comes back when the list is at its top. The field is not a row of the list: it
|
||||
* lies over the list's top padding and is shifted by as much as the list has scrolled.
|
||||
* While a search is on, and where the drawer does not use this (work tabs, a hidden or a
|
||||
* floating search field), the field stays in place.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun moveSearchFieldWithList(appsView: ActivityAllAppsContainerView<*>) {
|
||||
val field = appsView.searchView ?: return
|
||||
val list = appsView.activeRecyclerView
|
||||
val scrolled = if (list == null || appsView.isSearching || !appsView.flexeraSearchFieldScrolls()) {
|
||||
0
|
||||
} else {
|
||||
scrolledFromTop(list)
|
||||
}
|
||||
// The field and the list's rows vanish at the same line, the top of the drawer's
|
||||
// content, which is the lower edge of the status bar. The rows are cut there by the
|
||||
// list's own top edge; the field is cut here, and travels no further than it takes to
|
||||
// be all above that line.
|
||||
val line = appsView.paddingTop
|
||||
val moved = min(scrolled, max(0, field.bottom - line))
|
||||
field.translationY = -moved.toFloat()
|
||||
val hidden = line - (field.top - moved)
|
||||
if (hidden > 0) {
|
||||
// The view copies the rectangle, so one scratch rectangle serves every call.
|
||||
fieldClip.set(0, hidden, field.width, max(hidden, field.height))
|
||||
field.clipBounds = fieldClip
|
||||
} else {
|
||||
field.clipBounds = null
|
||||
}
|
||||
}
|
||||
|
||||
private val fieldClip = Rect()
|
||||
|
||||
/**
|
||||
* How far [list] has scrolled from its top, in pixels, for as long as its first row is
|
||||
* still attached; after that, simply "far". Counting rows is exact, which the list's own
|
||||
* scroll offset, an estimate from row heights, is not.
|
||||
*/
|
||||
private fun scrolledFromTop(list: RecyclerView): Int {
|
||||
val first = list.getChildAt(0) ?: return 0
|
||||
return if (list.getChildAdapterPosition(first) == 0) {
|
||||
max(0, list.paddingTop - first.top)
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the drawer panel sits for a given all-apps progress (0 = open, 1 = closed). Launcher3
|
||||
* slides the panel up from the bottom; here it stays in place and fades (see
|
||||
@@ -197,12 +333,19 @@ object FlexeraDrawer {
|
||||
appsView: ActivityAllAppsContainerView<*>,
|
||||
dragLayer: BaseDragLayer<*>,
|
||||
ev: MotionEvent,
|
||||
) = scrubFromSwipe(appsView, dragLayer, ev.x, ev.y)
|
||||
|
||||
/** The same, for a finger position ([x], [y]) in the drag layer's coordinates. */
|
||||
@JvmStatic
|
||||
fun scrubFromSwipe(
|
||||
appsView: ActivityAllAppsContainerView<*>,
|
||||
dragLayer: BaseDragLayer<*>,
|
||||
x: Float,
|
||||
y: Float,
|
||||
) {
|
||||
val wave = waveAlphabet(appsView) ?: return
|
||||
val offset = layoutOffset(wave, dragLayer)
|
||||
val x = ev.x - offset.x
|
||||
val y = ev.y - offset.y
|
||||
if (wave.isScrubbing) wave.moveScrub(x, y) else wave.beginScrub(x, y)
|
||||
if (wave.isScrubbing) wave.moveScrub(x - offset.x, y - offset.y) else wave.beginScrub(x - offset.x, y - offset.y)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,7 +360,7 @@ object FlexeraDrawer {
|
||||
): Boolean {
|
||||
val wave = waveAlphabet(appsView) ?: return false
|
||||
val offset = layoutOffset(wave, dragLayer)
|
||||
return wave.isOnStripBand(ev.x - offset.x, ev.y - offset.y)
|
||||
return wave.isOnHomeBand(ev.x - offset.x, ev.y - offset.y)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,12 +379,27 @@ object FlexeraDrawer {
|
||||
return offset
|
||||
}
|
||||
|
||||
/**
|
||||
* A scrub that started from a swipe and ends sooner than this was not meant as one: nobody
|
||||
* reads a letter and means it in a fifth of a second. The swipe opens the plain list.
|
||||
*/
|
||||
const val MIN_SCRUB_MS = 200L
|
||||
|
||||
/** Ends a scrub started by [scrubFromSwipe]; the drawer stays open at the chosen letter. */
|
||||
@JvmStatic
|
||||
fun endScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
|
||||
waveAlphabet(appsView)?.endScrub()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a scrub started by [scrubFromSwipe] and undoes it, so the drawer opens the way an
|
||||
* ordinary swipe up leaves it.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancelScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
|
||||
waveAlphabet(appsView)?.cancelScrub()
|
||||
}
|
||||
|
||||
/**
|
||||
* A letter header. It carries its letter so that the adapter's diffing notices when the
|
||||
* header at a position changes, for example after an app is installed or removed.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.flexera.drawer
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
@@ -16,7 +17,9 @@ import kotlin.math.abs
|
||||
* touch. Nothing is drawn on the home screen; the letters appear with the drawer.
|
||||
*
|
||||
* The edge is shared with page swipes, taps and item drags, so this claims a touch only once it
|
||||
* has clearly moved up or down, and never while an item is being dragged. It is off by default.
|
||||
* has clearly moved up or down, and never while an item is being dragged. A touch that lifts
|
||||
* again within [FlexeraDrawer.MIN_SCRUB_MS] was a flick, not a scrub, and opens the plain list.
|
||||
* The setting is on unless the owner switches it off.
|
||||
*/
|
||||
class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchController {
|
||||
|
||||
@@ -26,6 +29,7 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
/** The current touch started on the edge and may still turn into a scrub. */
|
||||
private var candidate = false
|
||||
private var scrubbing = false
|
||||
private var scrubStartTime = 0L
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
|
||||
@@ -48,7 +52,11 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
dy > touchSlop && dy > dx * VERTICAL_BIAS -> {
|
||||
candidate = false
|
||||
scrubbing = true
|
||||
launcher.stateManager.goToState(LauncherState.ALL_APPS)
|
||||
scrubStartTime = SystemClock.uptimeMillis()
|
||||
// The drawer starts opening and the alphabet takes the finger in the same
|
||||
// moment. Opening comes first, because its start is when the drawer
|
||||
// puts its list in place, which must not undo the scrub's first jump.
|
||||
openDrawer()
|
||||
FlexeraDrawer.scrubFromSwipe(launcher.appsView, launcher.dragLayer, ev)
|
||||
return true
|
||||
}
|
||||
@@ -68,12 +76,28 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
scrubbing = false
|
||||
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
|
||||
if (SystemClock.uptimeMillis() - scrubStartTime < FlexeraDrawer.MIN_SCRUB_MS) {
|
||||
// A flick up that happened to start on the edge: open the plain list.
|
||||
FlexeraDrawer.cancelScrubFromSwipe(launcher.appsView)
|
||||
} else {
|
||||
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the drawer in [OPEN_MS]. Asking the state manager to go there would take the
|
||||
* drawer's own 600 ms, far too slow for a list that the finger is already scrubbing.
|
||||
*/
|
||||
private fun openDrawer() {
|
||||
launcher.stateManager.createAnimationToNewWorkspace(LauncherState.ALL_APPS, OPEN_MS).apply {
|
||||
dispatchOnStart()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canStart(ev: MotionEvent): Boolean =
|
||||
prefs.flexeraScrubFromHome.get() &&
|
||||
(ev.edgeFlags and Utilities.EDGE_NAV_BAR) == 0 &&
|
||||
@@ -86,5 +110,8 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
private companion object {
|
||||
/** A slide counts as vertical when it has moved this many times further up or down than sideways. */
|
||||
const val VERTICAL_BIAS = 2f
|
||||
|
||||
/** How long the drawer takes to open under a scrub; the same as after swipe up and hold. */
|
||||
const val OPEN_MS = 120L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package app.flexera.drawer
|
||||
|
||||
import android.app.Notification
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
import com.android.launcher3.BubbleTextView
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.dot.DotInfo
|
||||
import com.android.launcher3.model.data.ItemInfo
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Inline notification previews (PRD AD-11): in the drawer list, a row whose app has
|
||||
* notifications shows the latest one under the app name, smaller and dimmer.
|
||||
*
|
||||
* It rides on what Launcher3 already has for notification dots. The same notification access
|
||||
* feeds it, the same filter decides which notifications count, and a row is refreshed whenever
|
||||
* its dot is. Without notification access there are no dots and no previews.
|
||||
*
|
||||
* Launcher3's listener hands each notification to [onPosted], [onRemoved] or [onFullRefresh] on
|
||||
* its worker thread; the rows read the stored text on the main thread through [apply].
|
||||
*/
|
||||
object FlexeraNotificationPreview {
|
||||
|
||||
private class Preview(val text: String, val postTime: Long)
|
||||
|
||||
/** Latest text per notification key, for the notifications that currently show a dot. */
|
||||
private val previews = ConcurrentHashMap<String, Preview>()
|
||||
|
||||
private const val MAX_LENGTH = 140
|
||||
|
||||
/** The preview line's text size as a share of the app name's. */
|
||||
private const val TEXT_SCALE = 0.78f
|
||||
|
||||
/** White at 70%, so the app name stays the stronger of the two lines. */
|
||||
private const val TEXT_COLOR = 0xB3FFFFFF.toInt()
|
||||
|
||||
@JvmStatic
|
||||
fun onPosted(sbn: StatusBarNotification) {
|
||||
val text = previewText(sbn)
|
||||
if (text == null) previews.remove(sbn.key) else previews[sbn.key] = Preview(text, sbn.postTime)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onRemoved(sbn: StatusBarNotification) {
|
||||
previews.remove(sbn.key)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onFullRefresh(active: List<StatusBarNotification>) {
|
||||
previews.clear()
|
||||
active.forEach(::onPosted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets [icon]'s text for [info]: the app name, plus the latest notification under it when
|
||||
* [dot] says the app has any. Called for drawer rows whenever their dot state is applied.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun apply(icon: BubbleTextView, info: ItemInfo, dot: DotInfo?) {
|
||||
// Only rows of the drawer list have room for a second line.
|
||||
if (icon.getTag(R.id.flexera_drawer_row) == null) return
|
||||
val name = info.title ?: return
|
||||
val preview = if (dot != null && isEnabled(icon)) latest(dot) else null
|
||||
|
||||
if (preview == null) {
|
||||
if (icon.getTag(R.id.flexera_notification_preview) != null) {
|
||||
// The row was showing a preview, for this app earlier or for another app before
|
||||
// the view was reused. Back to the plain one-line name.
|
||||
icon.setTag(R.id.flexera_notification_preview, null)
|
||||
icon.setLines(1)
|
||||
icon.text = name
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The text is set afresh every time. Binding a row puts the plain name back first, so
|
||||
// what the row showed before says nothing about what it shows now.
|
||||
|
||||
// Keep a long app name on its own line, so the preview is never pushed off the row.
|
||||
val width = icon.width - icon.compoundPaddingLeft - icon.compoundPaddingRight
|
||||
val firstLine = if (width > 0) {
|
||||
TextUtils.ellipsize(name, icon.paint, width.toFloat(), TextUtils.TruncateAt.END)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
val label = SpannableStringBuilder(firstLine).append('\n')
|
||||
val start = label.length
|
||||
label.append(preview)
|
||||
label.setSpan(RelativeSizeSpan(TEXT_SCALE), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
label.setSpan(ForegroundColorSpan(TEXT_COLOR), start, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
|
||||
icon.setLines(2)
|
||||
icon.ellipsize = TextUtils.TruncateAt.END
|
||||
icon.text = label
|
||||
icon.setTag(R.id.flexera_notification_preview, true)
|
||||
}
|
||||
|
||||
private fun isEnabled(icon: BubbleTextView): Boolean =
|
||||
PreferenceManager.getInstance(icon.context).flexeraNotificationPreviews.get()
|
||||
|
||||
/** The text of the newest of the app's notifications that has any text. */
|
||||
private fun latest(dot: DotInfo): String? {
|
||||
var newest: Preview? = null
|
||||
for (key in dot.notificationKeys) {
|
||||
val candidate = previews[key.notificationKey] ?: continue
|
||||
if (newest == null || candidate.postTime > newest.postTime) newest = candidate
|
||||
}
|
||||
return newest?.text
|
||||
}
|
||||
|
||||
/** "Title: text" in one line, or whichever of the two the notification has. */
|
||||
private fun previewText(sbn: StatusBarNotification): String? {
|
||||
val extras = sbn.notification.extras ?: return null
|
||||
val title = extras.getCharSequence(Notification.EXTRA_TITLE).oneLine()
|
||||
val text = extras.getCharSequence(Notification.EXTRA_TEXT).oneLine()
|
||||
val combined = when {
|
||||
title.isEmpty() -> text
|
||||
text.isEmpty() -> title
|
||||
else -> "$title: $text"
|
||||
}
|
||||
return combined.take(MAX_LENGTH).ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun CharSequence?.oneLine(): String =
|
||||
this?.toString()?.replace('\n', ' ')?.trim().orEmpty()
|
||||
}
|
||||
@@ -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
|
||||
@@ -24,6 +25,8 @@ import androidx.core.view.doOnNextLayout
|
||||
import androidx.customview.widget.ExploreByTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.allapps.ActivityAllAppsContainerView
|
||||
import kotlin.math.abs
|
||||
@@ -35,16 +38,22 @@ import kotlin.math.roundToInt
|
||||
/**
|
||||
* The wave alphabet: a strip of letters down one edge of the drawer (PRD AD-4 to AD-6).
|
||||
*
|
||||
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
|
||||
* Only letters that have apps are shown, under a magnifier that stands for the search field at
|
||||
* the top of the list. Touching the strip and sliding along it jumps the list
|
||||
* to the letter under the finger, with a haptic tick on every change. While the finger is down,
|
||||
* the letters near it bulge away from the edge in a wave, and the wave reaches further the
|
||||
* further the finger moves in from the edge, so the current letter always sits beside the thumb
|
||||
* instead of under it.
|
||||
* the letters near it bulge away from the edge in a wide wave that curves around the thumb.
|
||||
* The wave's peak, where the current letter is, stays a thumb's width beyond the finger however
|
||||
* far in from the edge the finger is, and just beyond the peak a bubble shows that letter
|
||||
* large. The look follows Niagara Launcher's alphabet, the owner's reference.
|
||||
*
|
||||
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15).
|
||||
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15), and low
|
||||
* on that edge, with its letters close together, so that the first letter is as easy to reach
|
||||
* as the last.
|
||||
*
|
||||
* The view covers the whole drawer so it has room to draw the wave, but it only takes touches
|
||||
* that start on the strip; every other touch falls through to the list underneath.
|
||||
* that start on the strip; every other touch falls through to the list underneath. The very
|
||||
* edge is also where Android starts its Back swipe. The strip leaves that to Android: a touch
|
||||
* there becomes a scrub only once it moves up or down or rests, and a sideways swipe goes back.
|
||||
*
|
||||
* [beginScrub], [moveScrub] and [endScrub] are public so that another gesture, such as swipe up
|
||||
* and hold from the home screen (PRD AD-17), can hand its touch over to the alphabet.
|
||||
@@ -58,7 +67,8 @@ class WaveAlphabetView(
|
||||
private val appsView: ActivityAllAppsContainerView<*>,
|
||||
) : View(context) {
|
||||
|
||||
private data class Letter(val label: String, val adapterPosition: Int)
|
||||
/** One stop on the strip: a letter, or the search field at the top of the list ([isSearch]). */
|
||||
private data class Letter(val label: String, val adapterPosition: Int, val isSearch: Boolean = false)
|
||||
|
||||
private val res = context.resources
|
||||
private val onLeft = FlexeraDrawer.alphabetOnLeft(context)
|
||||
@@ -66,10 +76,15 @@ class WaveAlphabetView(
|
||||
private val edgeInset = res.getDimension(R.dimen.flexera_wave_edge_inset)
|
||||
private val maxSpacing = res.getDimension(R.dimen.flexera_wave_max_spacing)
|
||||
private val verticalMargin = res.getDimension(R.dimen.flexera_wave_vertical_margin)
|
||||
private val bottomOffset = res.getDimension(R.dimen.flexera_wave_bottom_offset)
|
||||
private val fingerGap = res.getDimension(R.dimen.flexera_wave_finger_gap)
|
||||
private val minReach = res.getDimension(R.dimen.flexera_wave_min_reach)
|
||||
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
|
||||
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
|
||||
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
|
||||
private val headerHeight = FlexeraDrawer.headerHeight(context)
|
||||
private val homeTouchWidth = res.getDimension(R.dimen.flexera_wave_home_touch_width)
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
|
||||
/** The section name Launcher3 gives the work profile's info card, an "i" in a circle. */
|
||||
private val workInfoSection = res.getString(R.string.work_profile_edu_section)
|
||||
@@ -91,6 +106,43 @@ class WaveAlphabetView(
|
||||
}
|
||||
private val boldTypeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
|
||||
// For the magnifier: one stroke with the letters' shadow, one without for the bubble.
|
||||
private val markPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
setShadowLayer(
|
||||
res.getDimension(R.dimen.flexera_drawer_text_shadow_radius),
|
||||
0f,
|
||||
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
|
||||
FlexeraDrawer.TEXT_SHADOW_COLOR,
|
||||
)
|
||||
}
|
||||
private val plainMarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
// The bubble that shows the selected letter large, beyond the wave's peak: a light disc
|
||||
// with a dark letter, the one thing on the dark drawer that is not white on dark.
|
||||
private val bubbleRadius = res.getDimension(R.dimen.flexera_wave_bubble_radius)
|
||||
private val bubbleGap = res.getDimension(R.dimen.flexera_wave_bubble_gap)
|
||||
private val bubbleTextSize = res.getDimension(R.dimen.flexera_wave_bubble_text_size)
|
||||
private val bubbleEdgeMargin = res.getDimension(R.dimen.flexera_wave_bubble_edge_margin)
|
||||
private val bubblePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = BUBBLE_COLOR
|
||||
setShadowLayer(
|
||||
res.getDimension(R.dimen.flexera_wave_bubble_shadow_radius),
|
||||
0f,
|
||||
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
|
||||
BUBBLE_SHADOW_COLOR,
|
||||
)
|
||||
}
|
||||
private val bubbleTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = BUBBLE_TEXT_COLOR
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = boldTypeface
|
||||
}
|
||||
|
||||
private var letters: List<Letter> = emptyList()
|
||||
private var letterYs = FloatArray(0)
|
||||
private var stripTop = 0f
|
||||
@@ -106,11 +158,25 @@ class WaveAlphabetView(
|
||||
private var fingerY = 0f
|
||||
private var selected = -1
|
||||
|
||||
// A touch that began on the edge strip Android uses for Back, before it is known whether
|
||||
// it is a scrub: where it went down, where it is now, and the timer that makes a touch
|
||||
// at rest a scrub.
|
||||
private var waitingForBack = false
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
private var lastX = 0f
|
||||
private var lastY = 0f
|
||||
private val scrubAfterRest = Runnable {
|
||||
if (waitingForBack) {
|
||||
waitingForBack = false
|
||||
beginScrub(lastX, lastY)
|
||||
}
|
||||
}
|
||||
|
||||
/** 0 when the strip is at rest, 1 when the wave is fully out. Animated on touch down and up. */
|
||||
private var press = 0f
|
||||
private var pressAnimator: ValueAnimator? = null
|
||||
|
||||
private val exclusionRect = Rect()
|
||||
private val location = IntArray(2)
|
||||
private val otherLocation = IntArray(2)
|
||||
|
||||
@@ -138,10 +204,18 @@ class WaveAlphabetView(
|
||||
if (result.isNotEmpty() && result.last().label == label) continue
|
||||
result.add(Letter(label, section.position))
|
||||
}
|
||||
// The search field is the list's first row in all but name, and scrolls away with it.
|
||||
// A magnifier above the letters says it is there and jumps back to it.
|
||||
if (result.isNotEmpty() && hasSearchField()) result.add(0, Letter("", 0, isSearch = true))
|
||||
return result
|
||||
}
|
||||
|
||||
private fun hasSearchField(): Boolean =
|
||||
!PreferenceManager2.getInstance(context).hideAppDrawerSearchBar.firstCached()
|
||||
|
||||
private fun refreshIfChanged() {
|
||||
// Any new layout may have moved the list; keep the search field riding on it.
|
||||
FlexeraDrawer.moveSearchFieldWithList(appsView)
|
||||
val searching = appsView.isSearching
|
||||
val fresh = readLetters()
|
||||
if (fresh != letters || searching != wasSearching) {
|
||||
@@ -161,7 +235,8 @@ class WaveAlphabetView(
|
||||
appsView.activeRecyclerView?.let { rv ->
|
||||
getLocationInWindow(location)
|
||||
rv.getLocationInWindow(otherLocation)
|
||||
top = (otherLocation[1] - location[1]).toFloat().coerceAtLeast(0f)
|
||||
// The list's rows start below its top padding, which holds the search field.
|
||||
top = (otherLocation[1] - location[1] + rv.paddingTop).toFloat().coerceAtLeast(0f)
|
||||
}
|
||||
val bottomInset = rootWindowInsets?.systemWindowInsetBottom ?: 0
|
||||
val availableTop = top + verticalMargin
|
||||
@@ -176,8 +251,15 @@ class WaveAlphabetView(
|
||||
} else {
|
||||
val spacing = min(maxSpacing, (availableBottom - availableTop) / count)
|
||||
val blockHeight = spacing * count
|
||||
// Few letters: keep the block in the middle of the edge, where a thumb rests.
|
||||
stripTop = availableTop + (availableBottom - availableTop - blockHeight) / 2f
|
||||
// The stretch of edge a whole alphabet would take sits low, where a thumb reaches
|
||||
// without stretching: its end a little above the navigation bar, its start
|
||||
// wherever that puts it. The letters there are sit in the middle of that stretch,
|
||||
// so that a short alphabet is in the middle of the thumb's reach and not at its
|
||||
// lower end. Only letters that need the whole edge reach up to where the list starts.
|
||||
val fullCount = FULL_ALPHABET + if (letters.first().isSearch) 1 else 0
|
||||
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * fullCount))
|
||||
val zoneBottom = availableBottom - min(bottomOffset, availableBottom - availableTop - zoneHeight)
|
||||
stripTop = zoneBottom - zoneHeight + (zoneHeight - blockHeight) / 2f
|
||||
stripBottom = stripTop + blockHeight
|
||||
for (i in 0 until count) {
|
||||
letterYs[i] = stripTop + spacing * (i + 0.5f)
|
||||
@@ -187,16 +269,9 @@ class WaveAlphabetView(
|
||||
restingLetterSize = min(letterSize, spacing * MAX_LETTER_SHARE_OF_SLOT)
|
||||
}
|
||||
|
||||
// The strip sits where Android's back swipe starts. A home app may reserve the whole
|
||||
// edge, so the scrub never turns into Back.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (onLeft) {
|
||||
exclusionRect.set(0, stripTop.toInt(), touchWidth.toInt(), stripBottom.toInt())
|
||||
} else {
|
||||
exclusionRect.set((width - touchWidth).toInt(), stripTop.toInt(), width, stripBottom.toInt())
|
||||
}
|
||||
systemGestureExclusionRects = listOf(exclusionRect)
|
||||
}
|
||||
// The strip does not reserve the edge for itself: Android's Back swipe starts there,
|
||||
// and the owner closes the drawer with it. The two share the edge by direction, see
|
||||
// onTouchEvent.
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
@@ -212,6 +287,7 @@ class WaveAlphabetView(
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
|
||||
stopWaitingForBack()
|
||||
pressAnimator?.cancel()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
@@ -225,29 +301,36 @@ class WaveAlphabetView(
|
||||
// Where the letters rest, and which way "away from the edge" points.
|
||||
val baseX = if (onLeft) edgeInset else width - edgeInset
|
||||
val inward = if (onLeft) 1f else -1f
|
||||
// The wave's peak sits a fixed gap before the finger, so it reaches further out the
|
||||
// further the finger is from the edge.
|
||||
// The wave's peak sits a fixed gap beyond the finger, on the side away from the edge,
|
||||
// which is the side the thumb leaves uncovered. So the wave reaches further out the
|
||||
// further the finger is from the edge, up to nearly the whole width of the screen.
|
||||
val fingerDistance = (fingerX - baseX) * inward
|
||||
val maxReach = width * 0.7f
|
||||
val maxReach = max(minReach + 1f, width - edgeInset - farMargin)
|
||||
val fullReach = (fingerDistance + fingerGap).coerceIn(minReach, maxReach)
|
||||
val reach = fullReach * press
|
||||
// The wave is wide: several letters on each side of the finger ride it, so they curve
|
||||
// around the whole thumb and stay readable above and below it. A wave that reaches
|
||||
// far out is wider still, which keeps its shape.
|
||||
val spread = max(waveWidth, fullReach * WAVE_WIDTH_SHARE_OF_REACH)
|
||||
|
||||
// Darken the list under the wave where its letters cross icons and names. From the
|
||||
// right edge that only happens once the wave reaches well into the screen, as it does
|
||||
// after swipe up and hold; from the left edge the icons sit right beside the strip.
|
||||
// Darken the list a little while scrubbing, so the eye goes to the letters, and more
|
||||
// where the wave lies over icons and names: from the right edge that is once it reaches
|
||||
// well into the screen, as after swipe up and hold; from the left edge it is at once.
|
||||
if (press > 0f) {
|
||||
val overList = if (onLeft) 1f else ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
|
||||
val reachShare = ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
|
||||
val overList = if (onLeft) 1f else SCRUB_DIM_AT_EDGE + (1f - SCRUB_DIM_AT_EDGE) * reachShare
|
||||
val dim = (SCRUB_DIM * press * overList * 255).toInt()
|
||||
if (dim > 0) canvas.drawColor(Color.argb(dim, 0, 0, 0))
|
||||
}
|
||||
|
||||
for (i in letters.indices) {
|
||||
val y = letterYs[i]
|
||||
val nearness = if (press > 0f) bell((y - fingerY) / waveWidth) else 0f
|
||||
val nearness = if (press > 0f) bell((y - fingerY) / spread) else 0f
|
||||
val isSelected = i == selected && press > 0f
|
||||
|
||||
val scale = 1f + (if (isSelected) SELECTED_GROWTH else NEAR_GROWTH) * nearness * press
|
||||
paint.textSize = restingLetterSize * scale
|
||||
// Letters keep their size in the wave, so close neighbours never collide; the
|
||||
// selected one is bold and a touch larger. Its large copy is in the bubble.
|
||||
paint.textSize = restingLetterSize * (1f + (if (isSelected) SELECTED_GROWTH * press else 0f))
|
||||
paint.typeface = if (isSelected) boldTypeface else Typeface.DEFAULT
|
||||
paint.color = if (isSelected) {
|
||||
activeColor
|
||||
@@ -257,9 +340,49 @@ class WaveAlphabetView(
|
||||
|
||||
val x = baseX + inward * reach * nearness
|
||||
// Centre the glyph on the letter's line.
|
||||
val baseline = y - (paint.descent() + paint.ascent()) / 2f
|
||||
canvas.drawText(letters[i].label, x, baseline, paint)
|
||||
if (letters[i].isSearch) {
|
||||
drawSearchMark(canvas, x, y, paint.textSize * SEARCH_MARK_IN_LETTER_SIZES, paint.color, true)
|
||||
} else {
|
||||
val baseline = y - (paint.descent() + paint.ascent()) / 2f
|
||||
canvas.drawText(letters[i].label, x, baseline, paint)
|
||||
}
|
||||
}
|
||||
|
||||
// The selected letter, large, in a bubble just beyond the wave's peak and level with
|
||||
// the finger: the furthest thing from the thumb, so the thumb cannot hide it.
|
||||
if (press > 0f && selected in letters.indices) {
|
||||
val edgeRoom = bubbleRadius + bubbleEdgeMargin
|
||||
val cx = (baseX + inward * (reach + bubbleGap * press)).coerceIn(edgeRoom, max(edgeRoom, width - edgeRoom))
|
||||
val alpha = (press * 255).toInt()
|
||||
bubblePaint.alpha = alpha
|
||||
canvas.drawCircle(cx, fingerY, bubbleRadius * press, bubblePaint)
|
||||
bubbleTextPaint.alpha = alpha
|
||||
bubbleTextPaint.textSize = bubbleTextSize * press
|
||||
if (letters[selected].isSearch) {
|
||||
drawSearchMark(canvas, cx, fingerY, bubbleTextPaint.textSize, bubbleTextPaint.color, false)
|
||||
} else {
|
||||
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
|
||||
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a magnifier, the mark for the search field, [size] across and centred on
|
||||
* ([cx], [cy]): a ring with a short handle. Drawn by hand so that its line weight
|
||||
* matches letters of any size.
|
||||
*/
|
||||
private fun drawSearchMark(canvas: Canvas, cx: Float, cy: Float, size: Float, color: Int, shadow: Boolean) {
|
||||
val stroke = if (shadow) markPaint else plainMarkPaint
|
||||
stroke.color = color
|
||||
stroke.strokeWidth = size * 0.12f
|
||||
val radius = size * 0.29f
|
||||
val ringX = cx - size * 0.09f
|
||||
val ringY = cy - size * 0.09f
|
||||
canvas.drawCircle(ringX, ringY, radius, stroke)
|
||||
val rim = radius * 0.7071f
|
||||
val handle = size * 0.27f
|
||||
canvas.drawLine(ringX + rim, ringY + rim, ringX + rim + handle, ringY + rim + handle, stroke)
|
||||
}
|
||||
|
||||
private fun bell(distance: Float): Float = exp(-distance * distance)
|
||||
@@ -274,13 +397,29 @@ class WaveAlphabetView(
|
||||
* True when this point lies on the band along the edge that the letters occupy, whether or
|
||||
* not the drawer is open. Scrub from home (PRD AD-10) asks this while the drawer is closed.
|
||||
*/
|
||||
fun isOnStripBand(x: Float, y: Float): Boolean {
|
||||
fun isOnStripBand(x: Float, y: Float): Boolean = isOnBand(x, y, touchWidth)
|
||||
|
||||
/** The same for the home screen, where the band is narrower because nothing shows it. */
|
||||
fun isOnHomeBand(x: Float, y: Float): Boolean = isOnBand(x, y, homeTouchWidth)
|
||||
|
||||
private fun isOnBand(x: Float, y: Float, bandWidth: Float): Boolean {
|
||||
if (layoutDirty) computeLayout()
|
||||
if (letters.isEmpty()) return false
|
||||
val onEdge = if (onLeft) x <= touchWidth else x >= width - touchWidth
|
||||
val onEdge = if (onLeft) x <= bandWidth else x >= width - bandWidth
|
||||
return onEdge && y >= stripTop - verticalMargin && y <= stripBottom + verticalMargin
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a touch at [x] starts on the strip of edge where Android may begin its Back
|
||||
* swipe. With three-button navigation there is no such strip.
|
||||
*/
|
||||
private fun isInBackSwipeZone(x: Float): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false
|
||||
@Suppress("DEPRECATION")
|
||||
val insets = rootWindowInsets?.systemGestureInsets ?: return false
|
||||
return if (onLeft) x < insets.left else x > width - insets.right
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
@@ -288,16 +427,50 @@ class WaveAlphabetView(
|
||||
if (!isOnStrip(event.x, event.y)) return false
|
||||
// Keep the drawer from scrolling or closing under the thumb.
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
beginScrub(event.x, event.y)
|
||||
downX = event.x
|
||||
downY = event.y
|
||||
lastX = event.x
|
||||
lastY = event.y
|
||||
if (isInBackSwipeZone(event.x)) {
|
||||
// This may be the start of Android's Back swipe, which moves sideways.
|
||||
// Show nothing until the touch says which it is: a move up or down or a
|
||||
// short rest is a scrub, a move sideways is left to Android.
|
||||
waitingForBack = true
|
||||
postDelayed(scrubAfterRest, REST_BEFORE_SCRUB_MS)
|
||||
} else {
|
||||
beginScrub(event.x, event.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (!scrubbing) return false
|
||||
moveScrub(event.x, event.y)
|
||||
lastX = event.x
|
||||
lastY = event.y
|
||||
if (waitingForBack) {
|
||||
val dx = abs(event.x - downX)
|
||||
val dy = abs(event.y - downY)
|
||||
if (dy > touchSlop && dy >= dx) {
|
||||
stopWaitingForBack()
|
||||
beginScrub(event.x, event.y)
|
||||
} else if (dx > touchSlop) {
|
||||
// Sideways: Android takes the touch from here on, and with it Back.
|
||||
stopWaitingForBack()
|
||||
}
|
||||
} else if (scrubbing) {
|
||||
moveScrub(event.x, event.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
if (!scrubbing) return false
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (waitingForBack) {
|
||||
// Down and up without moving: a tap on a letter.
|
||||
stopWaitingForBack()
|
||||
beginScrub(event.x, event.y)
|
||||
}
|
||||
endScrub()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
stopWaitingForBack()
|
||||
endScrub()
|
||||
return true
|
||||
}
|
||||
@@ -305,6 +478,11 @@ class WaveAlphabetView(
|
||||
return scrubbing
|
||||
}
|
||||
|
||||
private fun stopWaitingForBack() {
|
||||
waitingForBack = false
|
||||
removeCallbacks(scrubAfterRest)
|
||||
}
|
||||
|
||||
/** Starts a scrub with the finger at ([x], [y]) in this view's coordinates. */
|
||||
fun beginScrub(x: Float, y: Float) {
|
||||
refreshIfChanged()
|
||||
@@ -316,6 +494,27 @@ class WaveAlphabetView(
|
||||
moveScrub(x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a scrub that began with the drawer closed and undoes it: the list goes to where a
|
||||
* drawer opened by a plain swipe stands, which is the top unless the owner has the drawer
|
||||
* remember its place. For a scrub that turns out to have been started by mistake.
|
||||
*
|
||||
* The list is not asked where it stood before the scrub. A closed drawer has been told to
|
||||
* go back to the top but lays its rows out again only when it opens, so until then the
|
||||
* rows still report the place the drawer was left at.
|
||||
*/
|
||||
fun cancelScrub() {
|
||||
if (!scrubbing) return
|
||||
endScrub()
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
rv.stopScroll()
|
||||
if (PreferenceManager2.getInstance(context).rememberPosition.firstCached()) {
|
||||
rv.restoreScrollPosition()
|
||||
} else {
|
||||
rv.scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the finger of a scrub started with [beginScrub]. */
|
||||
fun moveScrub(x: Float, y: Float) {
|
||||
if (!scrubbing || letters.isEmpty()) return
|
||||
@@ -327,7 +526,7 @@ class WaveAlphabetView(
|
||||
if (index != selected) {
|
||||
selected = index
|
||||
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
scrollListTo(letters[index].adapterPosition)
|
||||
scrollListTo(index)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
@@ -341,10 +540,31 @@ class WaveAlphabetView(
|
||||
|
||||
val isScrubbing: Boolean get() = scrubbing
|
||||
|
||||
private fun scrollListTo(adapterPosition: Int) {
|
||||
/**
|
||||
* Brings the group of the letter at [index] to the middle of the screen, where the thumb
|
||||
* is, instead of to the top: the group's middle on the list's middle, though a long group
|
||||
* keeps its header in view. Near either end of the list the list simply stops at its end.
|
||||
* The magnifier goes to the very top, where the search field is.
|
||||
*/
|
||||
private fun scrollListTo(index: Int) {
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
|
||||
rv.stopScroll()
|
||||
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(adapterPosition, 0)
|
||||
val letter = letters[index]
|
||||
if (letter.isSearch) {
|
||||
layoutManager.scrollToPositionWithOffset(0, 0)
|
||||
return
|
||||
}
|
||||
val groupEnd = letters.getOrNull(index + 1)?.adapterPosition
|
||||
?: rv.adapter?.itemCount
|
||||
?: (letter.adapterPosition + 2)
|
||||
val rows = (groupEnd - letter.adapterPosition - 1).coerceAtLeast(1)
|
||||
val groupHeight = headerHeight + rows * FlexeraDrawer.rowHeight(context)
|
||||
val top = max(rv.height * GROUP_HIGHEST_TOP, rv.height * GROUP_MIDDLE - groupHeight / 2f)
|
||||
layoutManager.scrollToPositionWithOffset(
|
||||
letter.adapterPosition,
|
||||
(top - rv.paddingTop).toInt().coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
private fun animatePress(target: Float, durationMs: Long) {
|
||||
@@ -405,7 +625,7 @@ class WaveAlphabetView(
|
||||
|
||||
private fun jumpTo(index: Int) {
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
scrollListTo(letters[index].adapterPosition)
|
||||
scrollListTo(index)
|
||||
rv.doOnNextLayout {
|
||||
chosenIndex = index
|
||||
chosenAtPosition = firstVisiblePosition()
|
||||
@@ -430,7 +650,9 @@ class WaveAlphabetView(
|
||||
if (!stripVisible()) return
|
||||
|
||||
val index = currentIndex()
|
||||
node.stateDescription = letters[index].label
|
||||
node.stateDescription = letters[index].let {
|
||||
if (it.isSearch) context.getString(R.string.flexera_alphabet_search) else it.label
|
||||
}
|
||||
node.setRangeInfo(
|
||||
AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
|
||||
AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_INT,
|
||||
@@ -459,18 +681,42 @@ class WaveAlphabetView(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** How much larger than its resting size the selected letter grows. */
|
||||
const val SELECTED_GROWTH = 1.5f
|
||||
/** Where a chosen letter's group is centred, and the highest its header may go, as shares of the list's height. */
|
||||
const val GROUP_MIDDLE = 0.5f
|
||||
const val GROUP_HIGHEST_TOP = 0.22f
|
||||
|
||||
/** How much its neighbours grow, tapering with distance from the finger. */
|
||||
const val NEAR_GROWTH = 0.5f
|
||||
/** The magnifier's size next to a letter of the same text size. */
|
||||
const val SEARCH_MARK_IN_LETTER_SIZES = 1.1f
|
||||
|
||||
/** How many letters a whole alphabet has; the stretch of edge kept for the strip is sized for it. */
|
||||
const val FULL_ALPHABET = 26
|
||||
|
||||
/** How much larger than its resting size the selected letter is in the wave itself. */
|
||||
const val SELECTED_GROWTH = 0.25f
|
||||
|
||||
/** The bubble: a near-white disc, its letter near-black, a soft shadow under it. */
|
||||
const val BUBBLE_COLOR = 0xFFF1F1F4.toInt()
|
||||
const val BUBBLE_TEXT_COLOR = 0xFF17181C.toInt()
|
||||
const val BUBBLE_SHADOW_COLOR = 0x66000000
|
||||
|
||||
/** The wave's spread up and down the edge, as a share of how far it reaches out. */
|
||||
const val WAVE_WIDTH_SHARE_OF_REACH = 0.36f
|
||||
|
||||
/** How dark the list gets under a wave that lies fully over it (0 = unchanged, 1 = black). */
|
||||
const val SCRUB_DIM = 0.55f
|
||||
|
||||
/** The share of [SCRUB_DIM] applied when the wave stays at the right edge, clear of the list. */
|
||||
const val SCRUB_DIM_AT_EDGE = 0.35f
|
||||
|
||||
/** The tallest a resting letter may be, as a share of the vertical room it has. */
|
||||
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
|
||||
|
||||
/**
|
||||
* How long a touch on the Back strip may rest before it counts as a scrub. Long
|
||||
* enough for a Back swipe to have started moving, short enough not to be felt.
|
||||
*/
|
||||
const val REST_BEFORE_SCRUB_MS = 130L
|
||||
|
||||
const val PRESS_IN_MS = 110L
|
||||
const val PRESS_OUT_MS = 220L
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +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
|
||||
@@ -12,12 +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
|
||||
@@ -30,9 +48,15 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -40,6 +64,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.flexera.update.CheckProblem
|
||||
import app.flexera.update.FlexeraRelease
|
||||
import app.flexera.update.FlexeraUpdater
|
||||
import app.flexera.update.UpdateFailure
|
||||
@@ -54,6 +79,9 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
|
||||
import app.lawnchair.ui.preferences.navigation.AboutLicenses
|
||||
import com.android.launcher3.BuildConfig
|
||||
import com.android.launcher3.R
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import com.google.zxing.qrcode.encoder.Encoder
|
||||
import kotlin.math.floor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -62,6 +90,12 @@ private const val LAWNCHAIR_SOURCE_URL = "https://github.com/LawnchairLauncher/l
|
||||
/** Release notes longer than this are cut off; the full text is on the release page. */
|
||||
private const val MAX_NOTES_LENGTH = 600
|
||||
|
||||
/** Large enough to scan from another phone held at arm's length. */
|
||||
private val QR_CODE_SIZE = 220.dp
|
||||
|
||||
/** The blank border around a QR code, in modules; the standard asks for four. */
|
||||
private const val QR_QUIET_ZONE_MODULES = 4
|
||||
|
||||
/**
|
||||
* Flexera Launcher's About screen.
|
||||
*
|
||||
@@ -93,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 {
|
||||
@@ -178,6 +212,13 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_update_heading)) }
|
||||
groupRows(updateRows)
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
|
||||
item {
|
||||
PreferenceGroupItem {
|
||||
ShareCard(scanUrl = FlexeraUpdater.LATEST_APK_URL, pageUrl = FlexeraUpdater.RELEASES_PAGE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
|
||||
groupRows(
|
||||
listOf<@Composable () -> Unit>(
|
||||
@@ -219,80 +260,296 @@ 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 = stringResource(id = R.string.flexera_update_check_failed),
|
||||
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 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(scanUrl: String, pageUrl: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
QrCode(
|
||||
text = scanUrl,
|
||||
description = stringResource(id = R.string.flexera_about_share_qr_description),
|
||||
modifier = Modifier.size(QR_CODE_SIZE),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.flexera_about_share_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = pageUrl.removePrefix("https://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A QR code for [text], made on the phone. It is black on white in every theme, with the blank
|
||||
* border the standard asks for, because that is what scanners read most reliably.
|
||||
*/
|
||||
@Composable
|
||||
private fun QrCode(text: String, description: String, modifier: Modifier = Modifier) {
|
||||
val modules = remember(text) { Encoder.encode(text, ErrorCorrectionLevel.M).matrix }
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
drawRect(Color.White)
|
||||
// Whole pixels per module, so neighbouring modules join without a seam.
|
||||
val count = modules.width + 2 * QR_QUIET_ZONE_MODULES
|
||||
val module = floor(size.minDimension / count)
|
||||
val left = floor((size.width - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
|
||||
val top = floor((size.height - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
|
||||
for (y in 0 until modules.height) {
|
||||
for (x in 0 until modules.width) {
|
||||
if (modules.get(x, y).toInt() == 1) {
|
||||
drawRect(
|
||||
color = Color.Black,
|
||||
topLeft = Offset(left + x * module, top + y * module),
|
||||
size = Size(module, module),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says why a check for updates failed and what to do about it, then the error's own words, which
|
||||
* are what someone looking for the cause will ask for.
|
||||
*/
|
||||
@Composable
|
||||
private fun checkFailureText(state: UpdateState.Failed): String {
|
||||
val host = FlexeraUpdater.SERVER_HOST
|
||||
val reason = when (state.problem) {
|
||||
CheckProblem.NO_PERMISSION -> stringResource(id = R.string.flexera_update_failed_no_permission)
|
||||
CheckProblem.SERVER_NOT_FOUND -> stringResource(id = R.string.flexera_update_failed_not_found, host)
|
||||
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
|
||||
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
|
||||
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
|
||||
CheckProblem.LOCAL_NETWORK -> stringResource(id = R.string.flexera_update_failed_local_network, host)
|
||||
CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
|
||||
}
|
||||
val detail = state.detail ?: return reason
|
||||
return reason + "\n" + stringResource(id = R.string.flexera_update_failed_detail, detail)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -341,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
|
||||
@@ -49,6 +52,11 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
|
||||
label = stringResource(id = R.string.suggestion_pref_screen_title),
|
||||
destination = Predictions,
|
||||
)
|
||||
SwitchPreference(
|
||||
label = stringResource(id = R.string.flexera_notification_previews),
|
||||
description = stringResource(id = R.string.flexera_notification_previews_description),
|
||||
adapter = prefs.flexeraNotificationPreviews.getAdapter(),
|
||||
)
|
||||
SwitchPreference(
|
||||
label = stringResource(id = R.string.pref_all_apps_remember_position_title),
|
||||
description = stringResource(id = R.string.pref_all_apps_remember_position_description),
|
||||
@@ -70,6 +78,18 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
|
||||
// No background colour or opacity here: the drawer is transparent with white text, and a
|
||||
// light panel behind white text would make the list unreadable.
|
||||
PreferenceGroup(heading = stringResource(id = R.string.style)) {
|
||||
ListPreference(
|
||||
adapter = prefs.flexeraDrawerRowSize.getAdapter(),
|
||||
entries = listOf(
|
||||
ListPreferenceEntry(FlexeraDrawer.RowSize.COMPACT.key) {
|
||||
stringResource(id = R.string.flexera_row_size_compact)
|
||||
},
|
||||
ListPreferenceEntry(FlexeraDrawer.RowSize.COMFY.key) {
|
||||
stringResource(id = R.string.flexera_row_size_comfy)
|
||||
},
|
||||
),
|
||||
label = stringResource(id = R.string.flexera_row_size),
|
||||
)
|
||||
SwitchPreference(
|
||||
label = stringResource(id = R.string.pref_all_apps_search_bar_background),
|
||||
adapter = prefs2.appDrawerSearchBarBackground.getAdapter(),
|
||||
|
||||
@@ -9,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,8 +26,13 @@ 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
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -31,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/** One published version of Flexera Launcher. */
|
||||
@@ -56,6 +66,39 @@ enum class UpdateFailure {
|
||||
WRONG_FILE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a check for updates failed, as far as the phone can tell. The About screen says it in
|
||||
* words, because "could not reach the server" alone leaves the owner guessing between the
|
||||
* connection, a VPN, the server and the phone's own settings.
|
||||
*/
|
||||
enum class CheckProblem {
|
||||
/** Android does not let this app use the network (a per-app switch on some systems). */
|
||||
NO_PERMISSION,
|
||||
|
||||
/** The server's name could not be looked up. */
|
||||
SERVER_NOT_FOUND,
|
||||
|
||||
/** The server was found but did not answer in time, or refused the connection. */
|
||||
NO_ANSWER,
|
||||
|
||||
/**
|
||||
* The server's address is on the network the phone itself is on (a server at home, reached
|
||||
* over the home Wi-Fi), and Android has not been told to let this app reach devices there.
|
||||
* Since Android 17 that takes a permission of its own; without it the connection is
|
||||
* dropped without a word and looks like a server that does not answer.
|
||||
*/
|
||||
LOCAL_NETWORK,
|
||||
|
||||
/** The encrypted connection could not be set up. */
|
||||
NOT_SECURE,
|
||||
|
||||
/** The server answered, with an error. */
|
||||
SERVER_ERROR,
|
||||
|
||||
/** The server answered with something that is not a release. */
|
||||
BAD_ANSWER,
|
||||
}
|
||||
|
||||
/** Where the updater currently stands. The About screen shows one row per state. */
|
||||
sealed interface UpdateState {
|
||||
/** Nothing has been checked since the launcher started. */
|
||||
@@ -72,8 +115,17 @@ sealed interface UpdateState {
|
||||
|
||||
data class ReadyToInstall(val release: FlexeraRelease, val file: File) : UpdateState
|
||||
|
||||
/** [release] is set when the failure happened after a newer version had been found. */
|
||||
data class Failed(val failure: UpdateFailure, val release: FlexeraRelease? = null) : UpdateState
|
||||
/**
|
||||
* [release] is set when the failure happened after a newer version had been found. For a
|
||||
* failed check, [problem] says why and [detail] carries the error's own words, for
|
||||
* whoever has to find the cause.
|
||||
*/
|
||||
data class Failed(
|
||||
val failure: UpdateFailure,
|
||||
val release: FlexeraRelease? = null,
|
||||
val problem: CheckProblem? = null,
|
||||
val detail: String? = null,
|
||||
) : UpdateState
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,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
|
||||
|
||||
@@ -111,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"
|
||||
@@ -133,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
|
||||
@@ -179,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)
|
||||
@@ -190,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 UpdateState.Failed(UpdateFailure.CHECK)
|
||||
mutableState.value = if (automatic) before else failedCheck(app, error)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -210,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) {
|
||||
@@ -219,6 +324,7 @@ object FlexeraUpdater {
|
||||
else -> null
|
||||
} ?: return
|
||||
val app = context.applicationContext
|
||||
requestInstall(app, awaitingPermission = false)
|
||||
mutableState.value = UpdateState.Downloading(release, 0f)
|
||||
|
||||
scope.launch {
|
||||
@@ -229,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)
|
||||
}
|
||||
}
|
||||
@@ -245,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}"),
|
||||
@@ -264,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")
|
||||
@@ -271,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
|
||||
@@ -302,13 +456,111 @@ object FlexeraUpdater {
|
||||
private fun isBusy(): Boolean =
|
||||
mutableState.value.let { it is UpdateState.Checking || it is UpdateState.Downloading }
|
||||
|
||||
/** The server's name without the scheme, for messages. */
|
||||
@JvmField
|
||||
val SERVER_HOST: String = SERVER.substringAfter("://")
|
||||
|
||||
private class NoNetworkPermissionException : IOException("This app has no network permission")
|
||||
private class ServerErrorException(status: Int) : IOException("The server answered HTTP $status")
|
||||
private class BadAnswerException(message: String) : IOException(message)
|
||||
private class LocalNetworkBlockedException : IOException("Android does not let this app reach the local network")
|
||||
|
||||
/**
|
||||
* The permission Android 17 asks of an app before it may reach devices on the local
|
||||
* network. Named by its text, because older Android versions, which the app also runs on,
|
||||
* do not know it.
|
||||
*/
|
||||
const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
|
||||
|
||||
/** The first Android version that keeps apps from the local network: Android 17. */
|
||||
private const val LOCAL_NETWORK_SDK = 37
|
||||
|
||||
/**
|
||||
* True when Android is keeping this app from the update server because the server is on
|
||||
* the phone's own network and the owner has not allowed local network access. Looks the
|
||||
* server's name up, so not for the main thread.
|
||||
*/
|
||||
private fun blockedFromLocalNetwork(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < LOCAL_NETWORK_SDK) return false
|
||||
if (context.checkSelfPermission(LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED) return false
|
||||
return try {
|
||||
// Looking a name up is allowed without the permission, also at a resolver at home.
|
||||
InetAddress.getAllByName(URL(SERVER).host).any(::isOnOwnNetwork)
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
} catch (e: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an address that this phone reaches directly, without a router in between: one
|
||||
* inside the range of one of its own network connections. That is what Android counts as
|
||||
* the local network.
|
||||
*/
|
||||
private fun isOnOwnNetwork(address: InetAddress): Boolean {
|
||||
if (address.isLoopbackAddress) return false
|
||||
if (address.isLinkLocalAddress) return true
|
||||
val target = address.address
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
|
||||
for (network in interfaces) {
|
||||
if (network.isLoopback || !network.isUp) continue
|
||||
for (own in network.interfaceAddresses) {
|
||||
val base = own.address?.address ?: continue
|
||||
if (base.size != target.size) continue
|
||||
var bits = own.networkPrefixLength.toInt()
|
||||
if (bits <= 0) continue
|
||||
var same = true
|
||||
for (i in base.indices) {
|
||||
if (bits <= 0) break
|
||||
val mask = if (bits >= 8) 0xFF else (0xFF shl (8 - bits)) and 0xFF
|
||||
if ((base[i].toInt() and mask) != (target[i].toInt() and mask)) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
bits -= 8
|
||||
}
|
||||
if (same) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Turns the error of a failed check into a state that says what kind of failure it was. */
|
||||
private fun failedCheck(context: Context, error: Throwable): UpdateState.Failed {
|
||||
val kind = when (error) {
|
||||
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
|
||||
is LocalNetworkBlockedException -> CheckProblem.LOCAL_NETWORK
|
||||
is UnknownHostException -> CheckProblem.SERVER_NOT_FOUND
|
||||
is SSLException -> CheckProblem.NOT_SECURE
|
||||
is ServerErrorException -> CheckProblem.SERVER_ERROR
|
||||
is BadAnswerException, is JSONException -> CheckProblem.BAD_ANSWER
|
||||
else -> CheckProblem.NO_ANSWER
|
||||
}
|
||||
// A server that did not answer may still be one that Android kept this app from, where
|
||||
// the look before the check could not tell (the network changed in between, say).
|
||||
val problem = if (kind == CheckProblem.NO_ANSWER && blockedFromLocalNetwork(context)) {
|
||||
CheckProblem.LOCAL_NETWORK
|
||||
} else {
|
||||
kind
|
||||
}
|
||||
// Android's own error classes keep their names in a release build; this file's are
|
||||
// renamed by the shrinker, and their messages say everything anyway.
|
||||
val name = error.javaClass.name.takeIf { it.startsWith("java") || it.startsWith("android") || it.startsWith("org.json") }
|
||||
?.substringAfterLast('.')
|
||||
val detail = listOfNotNull(name, error.message).joinToString(": ").take(MAX_DETAIL_LENGTH)
|
||||
return UpdateState.Failed(UpdateFailure.CHECK, problem = problem, detail = detail.ifEmpty { null })
|
||||
}
|
||||
|
||||
private const val MAX_DETAIL_LENGTH = 200
|
||||
|
||||
/** Returns the latest published release, or null when nothing has been published yet. */
|
||||
private fun fetchLatestRelease(context: Context): FlexeraRelease? {
|
||||
val connection = open(context, LATEST_RELEASE_URL)
|
||||
try {
|
||||
val status = connection.responseCode
|
||||
if (status == HttpURLConnection.HTTP_NOT_FOUND) return null
|
||||
if (status != HttpURLConnection.HTTP_OK) throw IOException("Update server answered HTTP $status")
|
||||
if (status != HttpURLConnection.HTTP_OK) throw ServerErrorException(status)
|
||||
|
||||
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
||||
val tag = json.getString("tag_name")
|
||||
@@ -326,7 +578,7 @@ object FlexeraUpdater {
|
||||
apkSize = asset.optLong("size", 0L),
|
||||
)
|
||||
}
|
||||
throw IOException("Release $tag has no APK attached")
|
||||
throw BadAnswerException("Release $tag has no APK attached")
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
@@ -373,7 +625,7 @@ object FlexeraUpdater {
|
||||
}
|
||||
|
||||
private fun open(context: Context, url: String): HttpURLConnection {
|
||||
if (!FlexeraNetwork.hasInternetPermission(context)) throw IOException("No INTERNET permission")
|
||||
if (!FlexeraNetwork.hasInternetPermission(context)) throw NoNetworkPermissionException()
|
||||
return (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
@@ -444,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)}" +
|
||||
"¤t=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
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
# build.gradle reads this and derives the Android version code from it
|
||||
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
|
||||
# flexera/release.ps1 raises it for every release.
|
||||
versionName=0.2.0
|
||||
versionName=0.15.0
|
||||
@@ -128,6 +128,8 @@ coil-compose = "io.coil-kt:coil-compose:2.7.0"
|
||||
google-material = "com.google.android.material:material:1.14.0"
|
||||
hoko-blur = "io.github.hokofly:hoko-blur:1.5.5"
|
||||
javax-inject = "javax.inject:javax.inject:1"
|
||||
# Flexera Launcher: QR code on the About screen (app.flexera.ui.FlexeraAbout).
|
||||
zxing-core = "com.google.zxing:core:3.5.3"
|
||||
kdrag0n-colorkt = "dev.kdrag0n:colorkt:1.0.5"
|
||||
leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.14"
|
||||
leakcanary-androidstudio = "com.android.tools.studio.leakcanary:leakcanary:1.0.0"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +218,14 @@ class PreferenceManager @Inject constructor(
|
||||
val flexeraAlphabetOnLeft = BoolPref("pref_flexeraAlphabetOnLeft", false, recreate)
|
||||
|
||||
// Flexera Launcher: whether a slide along the home screen's edge opens the drawer at a letter (PRD AD-10).
|
||||
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false)
|
||||
// On unless switched off, by the owner's decision of 12 November 2026.
|
||||
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", true)
|
||||
|
||||
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
|
||||
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
|
||||
|
||||
// Flexera Launcher: how large the rows of the drawer list are, "compact" or "comfy" (FlexeraDrawer.RowSize).
|
||||
val flexeraDrawerRowSize = StringPref("pref_flexeraDrawerRowSize", "compact", recreate)
|
||||
|
||||
override fun close() {
|
||||
TODO("Not yet implemented")
|
||||
|
||||
@@ -874,6 +874,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)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ fun BackupAndRestorePreference(
|
||||
subtitle = stringResource(R.string.restore_nova_backup_description),
|
||||
onClick = restoreNovaBackupOpener(),
|
||||
)
|
||||
// Flexera Launcher: home screens from a Smart Launcher backup (app.flexera.backup).
|
||||
ClickablePreference(
|
||||
label = stringResource(R.string.flexera_import_smart_launcher),
|
||||
subtitle = stringResource(R.string.flexera_import_smart_launcher_description),
|
||||
onClick = app.flexera.backup.smartLauncherImportOpener(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.navigation.toRoute
|
||||
import app.lawnchair.backup.ui.CreateBackupScreen
|
||||
import app.flexera.backup.smartLauncherImportGraph
|
||||
import app.lawnchair.backup.ui.restoreBackupGraph
|
||||
import app.lawnchair.backup.ui.restoreNovaBackupGraph
|
||||
import app.lawnchair.preferences.BasePreferenceManager
|
||||
@@ -252,5 +253,6 @@ fun PreferenceNavigation(
|
||||
|
||||
restoreBackupGraph()
|
||||
restoreNovaBackupGraph()
|
||||
smartLauncherImportGraph() // Flexera Launcher: Smart Launcher import
|
||||
}
|
||||
}
|
||||
@@ -224,3 +224,8 @@ data class RestoreBackup(val base64Uri: String) : PreferenceRoute
|
||||
|
||||
@Serializable
|
||||
data class RestoreNovaBackup(val base64Uri: String) : PreferenceRoute
|
||||
|
||||
// Flexera Launcher: import of a Smart Launcher backup (app.flexera.backup). The route has to
|
||||
// be declared here because PreferenceRoute is sealed.
|
||||
@Serializable
|
||||
data class ImportSmartLauncherBackup(val base64Uri: String) : PreferenceRoute
|
||||
@@ -3,6 +3,7 @@ package app.lawnchair.views
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import app.flexera.drawer.FlexeraDrawer
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.SystemUiController
|
||||
@@ -24,7 +25,10 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
|
||||
val threshold = STATUS_BAR_COLOR_FORCE_UPDATE_THRESHOLD
|
||||
val forceChange = visibility == VISIBLE &&
|
||||
alpha > threshold &&
|
||||
Color.alpha(mBackgroundColor) / (255f * drawerOpacity) > threshold
|
||||
// Flexera Launcher: the scrim is at full strength when it reaches the alpha of the
|
||||
// drawer's backdrop. Lawnchair measured against its drawer opacity setting, which
|
||||
// is 0 here.
|
||||
Color.alpha(mBackgroundColor) / Color.alpha(FlexeraDrawer.BACKDROP_COLOR).toFloat() > threshold
|
||||
with(systemUiController) {
|
||||
if (forceChange) {
|
||||
updateUiState(SystemUiController.UI_STATE_SCRIM_VIEW, !isScrimDark)
|
||||
@@ -34,9 +38,8 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
|
||||
}
|
||||
}
|
||||
|
||||
override fun isScrimDark() = if (drawerOpacity <= 0.3f) {
|
||||
!Themes.getAttrBoolean(context, R.attr.isWorkspaceDarkText)
|
||||
} else {
|
||||
super.isScrimDark()
|
||||
}
|
||||
// Flexera Launcher: judged by the scrim's own colour, as Launcher3 does. Lawnchair went by
|
||||
// the wallpaper here because its drawer scrim could be see-through; this drawer's backdrop
|
||||
// is always dark, and the status bar icons over it have to be light.
|
||||
override fun isScrimDark() = super.isScrimDark()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -219,6 +219,9 @@ public class AllAppsState extends LauncherState {
|
||||
} else {
|
||||
backgroundColor = ColorTokens.AllAppsScrimColor.resolveColor(launcher);
|
||||
}
|
||||
// Flexera Launcher: the drawer's backdrop is dark whatever the theme and the wallpaper,
|
||||
// so that the white names on it stay readable.
|
||||
backgroundColor = app.flexera.drawer.FlexeraDrawer.BACKDROP_COLOR;
|
||||
return new ScrimColors(backgroundColor, /* foregroundColor */ Color.TRANSPARENT);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package com.android.launcher3.uioverrides.touchcontrollers;
|
||||
|
||||
import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE;
|
||||
import static com.android.app.animation.Interpolators.DECELERATE;
|
||||
import static com.android.launcher3.LauncherAnimUtils.SCRIM_COLORS;
|
||||
import static com.android.launcher3.LauncherAnimUtils.newSingleUseCancelListener;
|
||||
import static com.android.launcher3.LauncherState.ALL_APPS;
|
||||
@@ -35,6 +36,7 @@ import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.PointF;
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ViewConfiguration;
|
||||
|
||||
@@ -81,10 +83,23 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
|
||||
private boolean mDidTouchStartInNavBar;
|
||||
|
||||
// Flexera Launcher: swipe up and hold hands the same touch to the wave alphabet (PRD AD-17).
|
||||
// "Started" is set when the pause is detected and the drawer begins to finish opening;
|
||||
// "ready" is set once it is fully open and the alphabet can take the finger's position.
|
||||
// "Started" is set when the pause is detected: the alphabet takes the finger at once, while
|
||||
// the drawer finishes opening under it. "Drawer open" is set when that has finished.
|
||||
private boolean mFlexeraScrubStarted;
|
||||
private boolean mFlexeraScrubReady;
|
||||
private boolean mFlexeraDrawerOpen;
|
||||
// Where the finger was at the last drag event, in the drag layer's coordinates.
|
||||
private float mFlexeraLastX;
|
||||
private float mFlexeraLastY;
|
||||
// How long the drawer takes to finish opening once a hold is detected. Short, because the
|
||||
// alphabet is already following the finger and the list has to be readable behind it.
|
||||
private static final long FLEXERA_HOLD_OPEN_MS = 120;
|
||||
// A finger that has not moved for this long is holding. Swipe to recents waits twice as
|
||||
// long; here a false alarm only opens the drawer the swipe was opening anyway.
|
||||
private static final long FLEXERA_HOLD_STILL_MS = 150;
|
||||
// A finger that lifts sooner than this after the hold was noticed was not holding; see
|
||||
// onDragEnd. Too short a time to have read the letter and meant it.
|
||||
private static final long FLEXERA_MIN_HOLD_MS = app.flexera.drawer.FlexeraDrawer.MIN_SCRUB_MS;
|
||||
private long mFlexeraScrubStartTime;
|
||||
private boolean mStartedOverview;
|
||||
private boolean mReachedOverview;
|
||||
// The last recorded displacement before we reached overview.
|
||||
@@ -177,9 +192,10 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
|
||||
|
||||
// Flexera Launcher: a pause during an ordinary swipe up to the drawer starts a scrub.
|
||||
mFlexeraScrubStarted = false;
|
||||
mFlexeraScrubReady = false;
|
||||
mFlexeraDrawerOpen = false;
|
||||
if (flexeraHoldToScrub()) {
|
||||
mMotionPauseDetector.setOnMotionPauseListener(this::onFlexeraHoldDetected);
|
||||
mMotionPauseDetector.setForcePauseTimeout(FLEXERA_HOLD_STILL_MS);
|
||||
}
|
||||
|
||||
if (mFromState == NORMAL && mToState == HINT_STATE) {
|
||||
@@ -219,8 +235,15 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
|
||||
if (mFlexeraScrubStarted) {
|
||||
// Flexera Launcher: lifting the finger ends the scrub and leaves the drawer open at
|
||||
// the chosen letter. If the drawer is still opening, its end callback finishes up.
|
||||
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
|
||||
if (mFlexeraScrubReady) {
|
||||
if (SystemClock.uptimeMillis() - mFlexeraScrubStartTime < FLEXERA_MIN_HOLD_MS) {
|
||||
// The finger left almost as soon as the hold was noticed, so it was not
|
||||
// holding: an ordinary swipe slowed down at its end. Open the drawer the way
|
||||
// that swipe would have, with the list where it was.
|
||||
app.flexera.drawer.FlexeraDrawer.cancelScrubFromSwipe(mLauncher.getAppsView());
|
||||
} else {
|
||||
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
|
||||
}
|
||||
if (mFlexeraDrawerOpen) {
|
||||
onSwipeInteractionCompleted(ALL_APPS);
|
||||
}
|
||||
} else if (mStartedOverview) {
|
||||
@@ -288,26 +311,34 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexera Launcher: the finger paused part-way through a swipe up. Finish opening the drawer
|
||||
* and, once it is open, let the same touch scrub the wave alphabet. This mirrors
|
||||
* {@link #onMotionPauseDetected()}, which does the same hand-over to Overview.
|
||||
* Flexera Launcher: the finger paused part-way through a swipe up. From this moment the same
|
||||
* touch scrubs the wave alphabet, with no wait: the alphabet takes the finger where it is,
|
||||
* and the drawer finishes opening under it.
|
||||
*/
|
||||
private void onFlexeraHoldDetected() {
|
||||
if (mCurrentAnimation == null || mFlexeraScrubStarted || !flexeraHoldToScrub()) {
|
||||
return;
|
||||
}
|
||||
mCurrentAnimation.getTarget().addListener(newSingleUseCancelListener(() ->
|
||||
mLauncher.getStateManager().goToState(ALL_APPS, true, forSuccessCallback(() -> {
|
||||
mFlexeraScrubReady = true;
|
||||
if (!mDetector.isDraggingState()) {
|
||||
// The finger lifted while the drawer was still opening.
|
||||
onSwipeInteractionCompleted(ALL_APPS);
|
||||
}
|
||||
}))));
|
||||
|
||||
mCurrentAnimation.getTarget().removeListener(mClearStateOnCancelListener);
|
||||
mCurrentAnimation.dispatchOnCancel();
|
||||
mFlexeraScrubStarted = true;
|
||||
mFlexeraScrubStartTime = SystemClock.uptimeMillis();
|
||||
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
|
||||
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
|
||||
|
||||
// Play the swipe's own animation to its end, as lifting the finger would, only faster.
|
||||
// Going through the state manager instead would take its full 600 ms opening time.
|
||||
mCurrentAnimation.setEndAction(() -> {
|
||||
mFlexeraDrawerOpen = true;
|
||||
if (!mDetector.isDraggingState()) {
|
||||
// The finger lifted while the drawer was still opening.
|
||||
onSwipeInteractionCompleted(ALL_APPS);
|
||||
}
|
||||
});
|
||||
ValueAnimator anim = mCurrentAnimation.getAnimationPlayer();
|
||||
anim.setFloatValues(
|
||||
Utilities.boundToRange(mCurrentAnimation.getProgressFraction(), 0f, 1f), 1f);
|
||||
anim.setDuration(FLEXERA_HOLD_OPEN_MS).setInterpolator(DECELERATE);
|
||||
mCurrentAnimation.dispatchOnStart();
|
||||
anim.start();
|
||||
VibratorWrapper.INSTANCE.get(mLauncher).vibrate(OVERVIEW_HAPTIC);
|
||||
}
|
||||
|
||||
@@ -326,13 +357,15 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
|
||||
|
||||
@Override
|
||||
public boolean onDrag(float yDisplacement, float xDisplacement, MotionEvent event) {
|
||||
// Flexera Launcher: remembered so that a hold, which can be noticed between two touch
|
||||
// events, knows where the finger is.
|
||||
mFlexeraLastX = event.getX();
|
||||
mFlexeraLastY = event.getY();
|
||||
if (mFlexeraScrubStarted) {
|
||||
// Flexera Launcher: the swipe has become a scrub; the finger now moves the alphabet,
|
||||
// not the drawer. Until the drawer has finished opening there is nothing to move yet.
|
||||
if (mFlexeraScrubReady) {
|
||||
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
|
||||
mLauncher.getAppsView(), mLauncher.getDragLayer(), event);
|
||||
}
|
||||
// not the drawer.
|
||||
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
|
||||
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
|
||||
return true;
|
||||
}
|
||||
if (mStartedOverview) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -76,6 +76,9 @@ public class MotionPauseDetector {
|
||||
private boolean mDisallowPause;
|
||||
// Time at which speed became < mSpeedSlow (only used if mMakePauseHarderToTrigger == true).
|
||||
private long mSlowStartTime;
|
||||
// Flexera Launcher: how long without motion counts as a pause. A gesture may shorten it
|
||||
// (see setForcePauseTimeout); clear() puts the stock value back.
|
||||
private long mForcePauseTimeoutMs = FORCE_PAUSE_TIMEOUT;
|
||||
|
||||
public MotionPauseDetector(Context context) {
|
||||
this(context, false);
|
||||
@@ -122,6 +125,15 @@ public class MotionPauseDetector {
|
||||
mIsTrackpadGesture = isTrackpadGesture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexera Launcher: sets how long the finger must stay still before that alone counts as a
|
||||
* pause, for the gesture in progress. Swipe up and hold (PRD AD-17) uses a shorter time
|
||||
* than the stock 300 ms, so the alphabet answers a hold without a noticeable wait.
|
||||
*/
|
||||
public void setForcePauseTimeout(long timeoutMs) {
|
||||
mForcePauseTimeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param disallowPause If true, we will not detect any pauses until this is set to false again.
|
||||
*/
|
||||
@@ -153,7 +165,7 @@ public class MotionPauseDetector {
|
||||
? TEST_HARNESS_TRIGGER_TIMEOUT
|
||||
: mMakePauseHarderToTrigger
|
||||
? HARDER_TRIGGER_TIMEOUT
|
||||
: FORCE_PAUSE_TIMEOUT;
|
||||
: mForcePauseTimeoutMs;
|
||||
mForcePauseTimeout.setAlarm(timeoutMs);
|
||||
float newVelocity = mVelocityProvider.addMotionEvent(ev, ev.getPointerId(pointerIndex));
|
||||
if (mPreviousVelocity != null) {
|
||||
@@ -262,6 +274,7 @@ public class MotionPauseDetector {
|
||||
mIsTrackpadGesture = false;
|
||||
mIsPaused = mHasEverBeenPaused = false;
|
||||
mSlowStartTime = 0;
|
||||
mForcePauseTimeoutMs = FORCE_PAUSE_TIMEOUT;
|
||||
mForcePauseTimeout.cancelAlarm();
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,8 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
|
||||
|
||||
private boolean mLayoutHorizontal;
|
||||
private final boolean mIsRtl;
|
||||
private final int mIconSize;
|
||||
// Flexera Launcher: not final, see setIconSizeOverride.
|
||||
private int mIconSize;
|
||||
|
||||
@ViewDebug.ExportedProperty(category = "launcher")
|
||||
private boolean mHideBadge = false;
|
||||
@@ -1413,6 +1414,11 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
|
||||
setContentDescription(itemInfo.contentDescription);
|
||||
}
|
||||
}
|
||||
// Flexera Launcher: a row in the drawer list shows its app's latest notification
|
||||
// under the app name (PRD AD-11).
|
||||
if (mDisplay == DISPLAY_ALL_APPS) {
|
||||
app.flexera.drawer.FlexeraNotificationPreview.apply(this, itemInfo, mDotInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1558,6 +1564,15 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
|
||||
return mIconSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexera Launcher: the rows of the drawer list come in more than one size, chosen by
|
||||
* the owner at run time, which the layout attribute cannot follow. Call before an icon
|
||||
* is applied.
|
||||
*/
|
||||
public void setIconSizeOverride(int iconSize) {
|
||||
mIconSize = iconSize;
|
||||
}
|
||||
|
||||
public boolean isDisplaySearchResult() {
|
||||
return mDisplay == DISPLAY_SEARCH_RESULT
|
||||
|| mDisplay == DISPLAY_SEARCH_RESULT_SMALL
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -192,6 +192,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
protected View mSearchContainer;
|
||||
protected SearchUiManager mSearchUiManager;
|
||||
protected boolean mUsingTabs;
|
||||
// Flexera Launcher: see replaceAppsRVContainer.
|
||||
private boolean mFlexeraSearchFieldScrolls;
|
||||
protected RecyclerViewFastScroller mTouchHandler;
|
||||
|
||||
/** {@code true} when rendered view is in search state instead of the scroll state. */
|
||||
@@ -849,9 +851,28 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
layoutBelowSearchContainer(getSearchRecyclerView(), /* tabs= */ false);
|
||||
}
|
||||
|
||||
// Flexera Launcher: the search field scrolls away with the list. For that the list
|
||||
// starts at the top of the drawer, not under the field, and keeps the field's room as
|
||||
// top padding (AdapterHolder.applyPadding); FlexeraDrawer.moveSearchFieldWithList then
|
||||
// moves the field as the list scrolls. Work tabs sit between the two and stay put, so
|
||||
// with tabs the field stays where it is, as before.
|
||||
mFlexeraSearchFieldScrolls =
|
||||
!showTabs && !isAppDrawerSearchBarHidden() && !isSearchBarFloating();
|
||||
if (mFlexeraSearchFieldScrolls
|
||||
&& rvContainer.getLayoutParams() instanceof RelativeLayout.LayoutParams lp) {
|
||||
lp.removeRule(RelativeLayout.ALIGN_TOP);
|
||||
lp.addRule(RelativeLayout.ALIGN_PARENT_TOP);
|
||||
lp.topMargin = 0;
|
||||
}
|
||||
|
||||
updateSearchResultsVisibility();
|
||||
}
|
||||
|
||||
/** Flexera Launcher: true while the search field scrolls away with the list. */
|
||||
public boolean flexeraSearchFieldScrolls() {
|
||||
return mFlexeraSearchFieldScrolls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up header rows and hides the header shell when neither tabs nor visible row content
|
||||
* requires it, keeping adapter top padding consistent with that visibility.
|
||||
@@ -917,6 +938,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
}
|
||||
|
||||
protected void updateHeaderScroll(int scrolledOffset) {
|
||||
// Flexera Launcher: the search field moves up with the list.
|
||||
app.flexera.drawer.FlexeraDrawer.moveSearchFieldWithList(this);
|
||||
if (isAppDrawerSearchBarHidden() && !mUsingTabs)
|
||||
return;
|
||||
|
||||
@@ -1421,6 +1444,11 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
setPadding(grid.allAppsLeftRightMargin, topPadding, grid.allAppsLeftRightMargin, 0);
|
||||
}
|
||||
InsettableFrameLayout.dispatchInsets(this, insets);
|
||||
// Flexera Launcher: the list's top padding follows the search field's place, which the
|
||||
// line above may just have changed.
|
||||
if (mFlexeraSearchFieldScrolls) {
|
||||
mAH.get(AdapterHolder.MAIN).applyPadding();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1905,7 +1933,17 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
if (isSearchBarFloating()) {
|
||||
bottomOffset += mSearchContainer.getHeight();
|
||||
}
|
||||
mRecyclerView.setPadding(mPadding.left, mPadding.top, mPadding.right,
|
||||
int top = mPadding.top;
|
||||
// Flexera Launcher: the room the search field takes at the top of the list, now
|
||||
// that the list starts above the field (see replaceAppsRVContainer): the
|
||||
// field's own distance from the top, and what used to be kept under its top edge.
|
||||
if (mFlexeraSearchFieldScrolls && isMain()) {
|
||||
top += getResources().getDimensionPixelSize(R.dimen.all_apps_header_top_margin);
|
||||
if (mSearchContainer.getLayoutParams() instanceof MarginLayoutParams field) {
|
||||
top += field.topMargin;
|
||||
}
|
||||
}
|
||||
mRecyclerView.setPadding(mPadding.left, top, mPadding.right,
|
||||
mPadding.bottom + bottomOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,13 +129,23 @@ public class NotificationListener extends NotificationListenerService {
|
||||
switch (message.what) {
|
||||
case MSG_NOTIFICATION_POSTED: {
|
||||
StatusBarNotification sbn = (StatusBarNotification) message.obj;
|
||||
mUiHandler.obtainMessage(notificationIsValidForUI(sbn)
|
||||
// Flexera Launcher: keep the text for the drawer's notification previews in step
|
||||
// with the dots, before the rows are told to refresh (PRD AD-11).
|
||||
boolean validForUi = notificationIsValidForUI(sbn);
|
||||
if (validForUi) {
|
||||
app.flexera.drawer.FlexeraNotificationPreview.onPosted(sbn);
|
||||
} else {
|
||||
app.flexera.drawer.FlexeraNotificationPreview.onRemoved(sbn);
|
||||
}
|
||||
mUiHandler.obtainMessage(validForUi
|
||||
? MSG_NOTIFICATION_POSTED : MSG_NOTIFICATION_REMOVED,
|
||||
toKeyPair(sbn)).sendToTarget();
|
||||
return true;
|
||||
}
|
||||
case MSG_NOTIFICATION_REMOVED: {
|
||||
StatusBarNotification sbn = (StatusBarNotification) message.obj;
|
||||
// Flexera Launcher: drop the notification's preview text with its dot.
|
||||
app.flexera.drawer.FlexeraNotificationPreview.onRemoved(sbn);
|
||||
mUiHandler.obtainMessage(MSG_NOTIFICATION_REMOVED,
|
||||
toKeyPair(sbn)).sendToTarget();
|
||||
|
||||
@@ -158,6 +168,8 @@ public class NotificationListener extends NotificationListenerService {
|
||||
} else {
|
||||
activeNotifications = new ArrayList<>();
|
||||
}
|
||||
// Flexera Launcher: rebuild the preview texts from the same list as the dots.
|
||||
app.flexera.drawer.FlexeraNotificationPreview.onFullRefresh(activeNotifications);
|
||||
|
||||
mUiHandler.obtainMessage(message.what, activeNotifications).sendToTarget();
|
||||
return true;
|
||||
|
||||
@@ -102,9 +102,11 @@ public class PopupDataProvider implements NotificationListener.NotificationsChan
|
||||
dotInfo = new DotInfo();
|
||||
mPackageUserToDotInfos.put(postedPackageUserKey, dotInfo);
|
||||
}
|
||||
if (dotInfo.addOrUpdateNotificationKey(notificationKey)) {
|
||||
updateNotificationDots(postedPackageUserKey::equals);
|
||||
}
|
||||
// Flexera Launcher: refresh the app's icons on every posted notification, not only when
|
||||
// the dot or its count changes. A notification that is updated in place keeps its dot
|
||||
// but brings new text for the drawer's preview line (PRD AD-11).
|
||||
dotInfo.addOrUpdateNotificationKey(notificationKey);
|
||||
updateNotificationDots(postedPackageUserKey::equals);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_SIZES
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.SizeF
|
||||
import com.android.launcher3.dagger.ApplicationContext
|
||||
import com.android.launcher3.util.Executors
|
||||
@@ -46,13 +47,24 @@ open class WidgetSizeHandler @Inject constructor(@ApplicationContext private val
|
||||
spanY: Int,
|
||||
) {
|
||||
Executors.UI_HELPER_EXECUTOR.execute {
|
||||
val widgetManager = AppWidgetManager.getInstance(context)
|
||||
val sizeOptions = WidgetSizes.getWidgetSizeOptions(context, info.provider, spanX, spanY)
|
||||
if (
|
||||
sizeOptions.getWidgetSizeList() !=
|
||||
widgetManager.getAppWidgetOptions(widgetId).getWidgetSizeList()
|
||||
)
|
||||
widgetManager.updateAppWidgetOptions(widgetId, sizeOptions)
|
||||
// Flexera Launcher: a widget that still waits for "tap to set up" has an id that
|
||||
// Android has reserved but not yet tied to a provider. Android answers a size
|
||||
// update for such an id with a NullPointerException of its own (seen on Android 17:
|
||||
// AppWidgetServiceImpl.updateAppWidgetOptions), which arrives here and used to end
|
||||
// the launcher whenever the grid reloaded with such a widget on a home screen. That
|
||||
// is the normal state after a layout import, so the answer is logged and dropped;
|
||||
// the size is sent again when the widget is set up.
|
||||
try {
|
||||
val widgetManager = AppWidgetManager.getInstance(context)
|
||||
val sizeOptions = WidgetSizes.getWidgetSizeOptions(context, info.provider, spanX, spanY)
|
||||
if (
|
||||
sizeOptions.getWidgetSizeList() !=
|
||||
widgetManager.getAppWidgetOptions(widgetId).getWidgetSizeList()
|
||||
)
|
||||
widgetManager.updateAppWidgetOptions(widgetId, sizeOptions)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w("WidgetSizeHandler", "Android refused the size of widget $widgetId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||