Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043e1e0c4a | ||
|
|
d82d488189 | ||
|
|
8894271daa | ||
|
|
3cb262617c |
No files matched your search
@@ -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,7 @@
|
||||
<!-- 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_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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -92,12 +94,7 @@ object FlexeraDrawer {
|
||||
// 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(),
|
||||
)
|
||||
icon.layoutParams.height = rowHeight(icon.context)
|
||||
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
|
||||
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
applySidePadding(icon)
|
||||
@@ -115,6 +112,20 @@ 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 {
|
||||
val res = context.resources
|
||||
return max(
|
||||
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
|
||||
(res.getDimension(R.dimen.flexera_drawer_row_text_size) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 +142,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
|
||||
|
||||
@@ -37,7 +37,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
|
||||
@@ -63,7 +64,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 +79,7 @@ class WaveAlphabetView(
|
||||
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
|
||||
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
|
||||
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
|
||||
private val headerHeight = res.getDimensionPixelSize(R.dimen.flexera_drawer_header_height)
|
||||
|
||||
/** 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 +101,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)
|
||||
@@ -166,10 +185,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 +216,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 +237,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
|
||||
@@ -298,8 +327,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 +345,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 -------------------------------------------------------------------------------
|
||||
@@ -403,7 +458,7 @@ class WaveAlphabetView(
|
||||
if (index != selected) {
|
||||
selected = index
|
||||
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
scrollListTo(letters[index].adapterPosition)
|
||||
scrollListTo(index)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
@@ -417,10 +472,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 +557,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 +582,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 +613,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
|
||||
|
||||
|
||||
@@ -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.6.0
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user