8 Commits
Author SHA1 Message Date
TMC 8cfa2fdc84 Release 0.7.0 2026-11-12 12:09:20 +01:00
TMCandClaude Fable 5.2 d0d1c031d0 Add a row size setting and give the screen edge back to Android's Back swipe
Row size (Settings, App drawer, Style): Compact is the list as it was, Comfy
has taller rows, larger icons and slightly larger names and letter headers.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 09:25:48 +01:00
18 changed files with 1465 additions and 65 deletions

No files matched your search

+18 -4
View File
@@ -8,6 +8,12 @@
<dimen name="flexera_drawer_row_icon_size">34dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">16dp</dimen>
<dimen name="flexera_drawer_row_text_size">15sp</dimen>
<!-- The "comfy" row size the owner can choose instead (Settings, App drawer): a little
larger all round, between compact and the first version. -->
<dimen name="flexera_drawer_row_height_comfy">58dp</dimen>
<dimen name="flexera_drawer_row_icon_size_comfy">40dp</dimen>
<dimen name="flexera_drawer_row_icon_gap_comfy">18dp</dimen>
<dimen name="flexera_drawer_row_text_size_comfy">16sp</dimen>
<!-- Row padding on the edge without the wave alphabet. The search bar lines up with it. -->
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
<!-- Row padding on the wave alphabet's edge, which is the end edge unless the owner moves it
@@ -15,10 +21,15 @@
<dimen name="flexera_drawer_row_padding_end">56dp</dimen>
<!-- Wave alphabet (PRD AD-4 to AD-6). -->
<!-- How wide a band along the edge starts a scrub when touched. -->
<dimen name="flexera_wave_touch_width">40dp</dimen>
<!-- Where the letters rest, measured from the edge. -->
<dimen name="flexera_wave_edge_inset">16dp</dimen>
<!-- How wide a band along the edge starts a scrub when touched in the open drawer. -->
<dimen name="flexera_wave_touch_width">60dp</dimen>
<!-- The same band on the home screen, where nothing shows it: narrower, so that fewer
swipes that only pass near the edge are taken for a scrub (PRD AD-10). -->
<dimen name="flexera_wave_home_touch_width">40dp</dimen>
<!-- Where the letters rest, measured from the edge. Inward of the strip of edge where
Android starts its Back swipe (about 24dp), so a thumb on the letters is not on it.
(First version: 16dp, with the whole edge reserved, which left no Back swipe.) -->
<dimen name="flexera_wave_edge_inset">30dp</dimen>
<dimen name="flexera_wave_letter_size">12sp</dimen>
<!-- Letters never sit further apart than this, however few there are. Close together, a
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
@@ -63,4 +74,7 @@
<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>
+45 -1
View File
@@ -27,6 +27,38 @@
<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_left_out">Left out</string>
<string name="flexera_import_own_widgets">Widgets of Smart Launcher itself</string>
<plurals name="flexera_import_own_widgets_value">
<item quantity="one">%1$d. It exists only inside Smart Launcher, so its place stays empty.</item>
<item quantity="other">%1$d. They exist only inside Smart Launcher, so their places stay empty.</item>
</plurals>
<string name="flexera_import_skipped">Other items</string>
<plurals name="flexera_import_skipped_value">
<item quantity="one">%1$d that this launcher cannot show or place</item>
<item quantity="other">%1$d that this launcher cannot show or place</item>
</plurals>
<string name="flexera_import_effect">What importing does</string>
<string name="flexera_import_effect_title">Replaces your current home screens</string>
<string name="flexera_import_effect_body">The home screens you have now are removed. The grid takes the size it has in the backup and the dock is switched off, because Smart Launcher has none; both can be changed again in Settings. Icons of apps that are not installed on this phone are dropped. Make a backup first if you want to be able to go back.</string>
<string name="flexera_import_action">Import</string>
<string name="flexera_import_done">Imported</string>
<string name="flexera_import_failed">The import failed</string>
<string name="flexera_import_unreadable">This is not a Smart Launcher backup that can be read</string>
<string name="flexera_about_share">Share</string>
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
<string name="flexera_about_share_qr_description">QR code that opens the download page</string>
@@ -42,6 +74,10 @@
<!-- 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>
@@ -57,7 +93,15 @@
<string name="flexera_update_check_idle">Flexera Launcher also checks on its own, once a day</string>
<string name="flexera_update_checking">Checking for updates…</string>
<string name="flexera_update_up_to_date">You have the latest version</string>
<string name="flexera_update_check_failed">Couldn\'t reach the update server. Check your connection, then tap to try again.</string>
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Tap to try again later.</string>
<!-- %1$s is the error in the system\'s own words, for whoever looks for the cause -->
<string name="flexera_update_failed_detail">Details: %1$s</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_download">Download version %1$s</string>
<!-- %1$s is a file size such as 24 MB -->
@@ -0,0 +1,619 @@
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.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 own widgets, 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
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 },
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) { withUnpackedBackup { dir -> readLayout(dir) } }
withContext(Dispatchers.IO) {
val tempDir = newTempDir()
try {
val staged = File(tempDir, STAGED_DB)
val shortcuts = writeLauncherDb(layout, staged)
applyGrid(layout)
val restored = context.getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME)
restored.parentFile?.mkdirs()
staged.copyTo(restored, overwrite = true)
// Opening the controller moves the restored file into place; the restore task
// then marks every item as freshly restored, which is what makes Launcher3 ask
// for the widgets anew and drop icons of apps that are not installed.
check(RestoreDbTask.performRestore(context, ModelDbController(context))) {
"Launcher3 did not accept the converted layout"
}
pinShortcuts(shortcuts)
} finally {
tempDir.deleteRecursively()
}
}
// 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) }
}
// ---- 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 and the preference files, and nothing else, into [dir]. Entry names
* come from the file and are not trusted: only the two known places are accepted, each file
* lands under its bare name, and a file of unreasonable size ends the import.
*/
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 entry = zip.nextEntry
while (entry != null) {
val name = entry.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)
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)
}
}
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?): 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 -> readWidgetProvider(db, row.widget)?.let { HomeWidget(place, it) }
row.launchable != null -> readLaunchable(db, row.launchable, place)
else -> null
}
when {
// The only widgets without a provider this can use are Smart Launcher's own.
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))
}
}
}
/** The provider of an ordinary app widget, or null for one of Smart Launcher's own. */
private fun readWidgetProvider(db: SQLiteDatabase, id: Long): String? =
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)
if (type != SL_WIDGET_TYPE_APP || component == null || user != OWNER_USER) {
null
} else {
component.flattenToString()
}
}
}
/** 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,
)
}
}
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
/** 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,198 @@
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.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"
}
}
+127 -11
View File
@@ -16,8 +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
@@ -45,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
@@ -91,15 +138,17 @@ object FlexeraDrawer {
// Other icons share the "all apps" display type (search results, for one); this tag
// tells list rows apart from them.
icon.setTag(R.id.flexera_drawer_row, true)
val textSize = res.getDimension(R.dimen.flexera_drawer_row_text_size)
// The row has to hold the name with a notification preview under it (PRD AD-11). At the
// usual font size the set height does; with a large system font the row grows instead.
icon.layoutParams.height = max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(textSize * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
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(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
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.
@@ -115,6 +164,24 @@ 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
@@ -131,6 +198,55 @@ object FlexeraDrawer {
*/
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
@@ -244,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)
}
/**
@@ -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
@@ -37,7 +38,8 @@ 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 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
@@ -49,7 +51,9 @@ import kotlin.math.roundToInt
* as the last.
*
* The view covers the whole drawer so it has room to draw the wave, but it only takes touches
* that start on the strip; every other touch falls through to the list underneath.
* that start on the strip; every other touch falls through to the list underneath. The very
* edge is also where Android starts its Back swipe. The strip leaves that to Android: a touch
* there becomes a scrub only once it moves up or down or rests, and a sideways swipe goes back.
*
* [beginScrub], [moveScrub] and [endScrub] are public so that another gesture, such as swipe up
* and hold from the home screen (PRD AD-17), can hand its touch over to the alphabet.
@@ -63,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)
@@ -77,6 +82,9 @@ class WaveAlphabetView(
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
private val headerHeight = 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)
@@ -98,6 +106,22 @@ 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)
@@ -134,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)
@@ -166,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) {
@@ -189,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
@@ -209,7 +256,8 @@ class WaveAlphabetView(
// wherever that puts it. The letters there are sit in the middle of that stretch,
// so that a short alphabet is in the middle of the thumb's reach and not at its
// lower end. Only letters that need the whole edge reach up to where the list starts.
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * FULL_ALPHABET))
val 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
@@ -221,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) {
@@ -246,6 +287,7 @@ class WaveAlphabetView(
override fun onDetachedFromWindow() {
viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
stopWaitingForBack()
pressAnimator?.cancel()
super.onDetachedFromWindow()
}
@@ -298,8 +340,12 @@ 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
@@ -312,11 +358,33 @@ class WaveAlphabetView(
canvas.drawCircle(cx, fingerY, bubbleRadius * press, bubblePaint)
bubbleTextPaint.alpha = alpha
bubbleTextPaint.textSize = bubbleTextSize * press
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
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)
// ---- Touch -------------------------------------------------------------------------------
@@ -329,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) {
@@ -343,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
}
@@ -360,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()
@@ -403,7 +526,7 @@ class WaveAlphabetView(
if (index != selected) {
selected = index
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
scrollListTo(letters[index].adapterPosition)
scrollListTo(index)
}
invalidate()
}
@@ -417,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) {
@@ -481,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()
@@ -506,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,
@@ -535,6 +681,13 @@ class WaveAlphabetView(
}
private companion object {
/** 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
/** 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
@@ -558,6 +711,12 @@ class WaveAlphabetView(
/** The tallest a resting letter may be, as a share of the vertical room it has. */
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
/**
* How long a touch on the Back strip may rest before it counts as a scrub. Long
* enough for a Back swipe to have started moving, short enough not to be felt.
*/
const val REST_BEFORE_SCRUB_MS = 130L
const val PRESS_IN_MS = 110L
const val PRESS_OUT_MS = 220L
+21 -1
View File
@@ -47,6 +47,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
@@ -294,7 +295,7 @@ private fun UpdateStatusRow(state: UpdateState) {
if (release == null) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_check_failed),
subtitle = checkFailureText(state),
onClick = { FlexeraUpdater.check(context) },
)
} else {
@@ -379,6 +380,25 @@ private fun QrCode(text: String, description: String, modifier: Modifier = Modif
}
}
/**
* 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.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
}
val detail = state.detail ?: return reason
return reason + "\n" + stringResource(id = R.string.flexera_update_failed_detail, detail)
}
@Composable
private fun downloadSizeText(context: Context, release: FlexeraRelease): String =
if (release.apkSize > 0) {
@@ -4,11 +4,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.flexera.drawer.FlexeraDrawer
import app.lawnchair.preferences.getAdapter
import app.lawnchair.preferences.preferenceManager
import app.lawnchair.preferences2.preferenceManager2
import app.lawnchair.ui.preferences.LocalIsExpandedScreen
import app.lawnchair.ui.preferences.components.NavigationActionPreference
import app.lawnchair.ui.preferences.components.controls.ListPreference
import app.lawnchair.ui.preferences.components.controls.ListPreferenceEntry
import app.lawnchair.ui.preferences.components.controls.SwitchPreference
import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
@@ -75,6 +78,18 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
// No background colour or opacity here: the drawer is transparent with white text, and a
// light panel behind white text would make the list unreadable.
PreferenceGroup(heading = stringResource(id = R.string.style)) {
ListPreference(
adapter = prefs.flexeraDrawerRowSize.getAdapter(),
entries = listOf(
ListPreferenceEntry(FlexeraDrawer.RowSize.COMPACT.key) {
stringResource(id = R.string.flexera_row_size_compact)
},
ListPreferenceEntry(FlexeraDrawer.RowSize.COMFY.key) {
stringResource(id = R.string.flexera_row_size_comfy)
},
),
label = stringResource(id = R.string.flexera_row_size),
)
SwitchPreference(
label = stringResource(id = R.string.pref_all_apps_search_bar_background),
adapter = prefs2.appDrawerSearchBarBackground.getAdapter(),
@@ -24,6 +24,8 @@ import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
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 +33,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 +59,31 @@ 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 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 +100,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
}
/**
@@ -190,7 +227,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(error)
return@launch
}
@@ -302,13 +339,41 @@ 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)
/** Turns the error of a failed check into a state that says what kind of failure it was. */
private fun failedCheck(error: Throwable): UpdateState.Failed {
val problem = when (error) {
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
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
}
// 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 +391,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 +438,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
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.4.0
versionName=0.7.0
@@ -224,6 +224,9 @@ class PreferenceManager @Inject constructor(
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
// Flexera Launcher: how large the rows of the drawer list are, "compact" or "comfy" (FlexeraDrawer.RowSize).
val flexeraDrawerRowSize = StringPref("pref_flexeraDrawerRowSize", "compact", recreate)
override fun close() {
TODO("Not yet implemented")
}
@@ -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(),
)
}
}
}
@@ -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
+11 -1
View File
@@ -195,7 +195,8 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
private boolean mLayoutHorizontal;
private final boolean mIsRtl;
private final int mIconSize;
// Flexera Launcher: not final, see setIconSizeOverride.
private int mIconSize;
@ViewDebug.ExportedProperty(category = "launcher")
private boolean mHideBadge = false;
@@ -1563,6 +1564,15 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
return mIconSize;
}
/**
* Flexera Launcher: the rows of the drawer list come in more than one size, chosen by
* the owner at run time, which the layout attribute cannot follow. Call before an icon
* is applied.
*/
public void setIconSizeOverride(int iconSize) {
mIconSize = iconSize;
}
public boolean isDisplaySearchResult() {
return mDisplay == DISPLAY_SEARCH_RESULT
|| mDisplay == DISPLAY_SEARCH_RESULT_SMALL
@@ -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);
}
}
@@ -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)
}
}
}