Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd1c8b195e | ||
|
|
5c4a6c346d | ||
|
|
d5a62cc6f5 | ||
|
|
8cfa2fdc84 | ||
|
|
d0d1c031d0 | ||
|
|
494364ffa2 | ||
|
|
80a56c01c9 | ||
|
|
043e1e0c4a | ||
|
|
d82d488189 | ||
|
|
8894271daa | ||
|
|
3cb262617c | ||
|
|
0959f36587 | ||
|
|
8aba905d9c |
No files matched your search
@@ -494,6 +494,8 @@ dependencies {
|
||||
implementation libs.kdrag0n.colorkt
|
||||
implementation libs.coil.compose
|
||||
implementation libs.xdrop.fuzzywuzzy
|
||||
// Flexera Launcher: encodes the QR code on the About screen. Nothing else of it is used.
|
||||
implementation libs.zxing.core
|
||||
|
||||
implementation libs.bundles.opto
|
||||
implementation libs.androidx.datastore.preferences
|
||||
|
||||
@@ -12,4 +12,31 @@
|
||||
<!-- Lets the app hand a downloaded update to Android's installer. The owner still confirms each install. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application>
|
||||
|
||||
<!--
|
||||
The picture widget that comes with the launcher. Not exported: only this launcher can
|
||||
place it, and only this launcher knows how to draw it.
|
||||
-->
|
||||
<receiver
|
||||
android:name="app.flexera.widget.PictureWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/flexera_picture_widget">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/flexera_picture_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Opens Android's photo picker for the picture widget; it has no screen of its own. -->
|
||||
<activity
|
||||
android:name="app.flexera.widget.PictureWidgetConfigActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Transparent" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A sun over a hill on a rounded plate: the stand-in for the picture widget's content. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="120"
|
||||
android:viewportHeight="120">
|
||||
<path
|
||||
android:fillColor="#FF3D4A70"
|
||||
android:pathData="M28,0h64a28,28 0,0 1,28 28v64a28,28 0,0 1,-28 28h-64a28,28 0,0 1,-28 -28v-64a28,28 0,0 1,28 -28z" />
|
||||
<path
|
||||
android:fillColor="#99FFFFFF"
|
||||
android:pathData="M84,36m-12,0a12,12 0,1 1,24 0a12,12 0,1 1,-24 0" />
|
||||
<path
|
||||
android:fillColor="#40FFFFFF"
|
||||
android:pathData="M0,120L0,104Q40,52 92,120Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What Android is told the picture widget looks like before it has anything to show. Flexera
|
||||
Launcher never shows it: it draws the widget itself (app.flexera.widget.PictureWidgetView).
|
||||
-->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/flexera_picture_widget_sample" />
|
||||
</FrameLayout>
|
||||
@@ -1,11 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Drawer list (PRD AD-2, AD-3). Rows are sized for thumb taps; 48dp is Android's minimum target. -->
|
||||
<dimen name="flexera_drawer_row_height">64dp</dimen>
|
||||
<!-- Smaller than a home-screen icon, so rows have air between them. -->
|
||||
<dimen name="flexera_drawer_row_icon_size">46dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_gap">20dp</dimen>
|
||||
<dimen name="flexera_drawer_row_text_size">17sp</dimen>
|
||||
<!-- Drawer list (PRD AD-2, AD-3). A compact list, at the owner's request: more apps within a
|
||||
thumb's reach. The row stays just above 48dp, Android's minimum for a tap target.
|
||||
(First version: 64dp rows, 46dp icons, 17sp names.) -->
|
||||
<dimen name="flexera_drawer_row_height">50dp</dimen>
|
||||
<!-- Well under a home-screen icon, so rows have air between them. -->
|
||||
<dimen name="flexera_drawer_row_icon_size">34dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_gap">16dp</dimen>
|
||||
<dimen name="flexera_drawer_row_text_size">15sp</dimen>
|
||||
<!-- The "comfy" row size the owner can choose instead (Settings, App drawer): a little
|
||||
larger all round, between compact and the first version. -->
|
||||
<dimen name="flexera_drawer_row_height_comfy">58dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_size_comfy">40dp</dimen>
|
||||
<dimen name="flexera_drawer_row_icon_gap_comfy">18dp</dimen>
|
||||
<dimen name="flexera_drawer_row_text_size_comfy">16sp</dimen>
|
||||
<!-- Row padding on the edge without the wave alphabet. The search bar lines up with it. -->
|
||||
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
|
||||
<!-- Row padding on the wave alphabet's edge, which is the end edge unless the owner moves it
|
||||
@@ -13,13 +21,25 @@
|
||||
<dimen name="flexera_drawer_row_padding_end">56dp</dimen>
|
||||
|
||||
<!-- Wave alphabet (PRD AD-4 to AD-6). -->
|
||||
<!-- How wide a band along the edge starts a scrub when touched. -->
|
||||
<dimen name="flexera_wave_touch_width">40dp</dimen>
|
||||
<!-- Where the letters rest, measured from the edge. -->
|
||||
<dimen name="flexera_wave_edge_inset">16dp</dimen>
|
||||
<!-- How wide a band along the edge starts a scrub when touched in the open drawer. -->
|
||||
<dimen name="flexera_wave_touch_width">60dp</dimen>
|
||||
<!-- The same band on the home screen, where nothing shows it: narrower, so that fewer
|
||||
swipes that only pass near the edge are taken for a scrub (PRD AD-10). -->
|
||||
<dimen name="flexera_wave_home_touch_width">40dp</dimen>
|
||||
<!-- Where the letters rest, measured from the edge. Inward of the strip of edge where
|
||||
Android starts its Back swipe (about 24dp), so a thumb on the letters is not on it.
|
||||
(First version: 16dp, with the whole edge reserved, which left no Back swipe.) -->
|
||||
<dimen name="flexera_wave_edge_inset">30dp</dimen>
|
||||
<dimen name="flexera_wave_letter_size">12sp</dimen>
|
||||
<!-- Letters never sit further apart than this, however few there are. -->
|
||||
<dimen name="flexera_wave_max_spacing">30dp</dimen>
|
||||
<!-- Letters never sit further apart than this, however few there are. Close together, a
|
||||
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
|
||||
the stretch of edge a thumb covers without shifting the grip. (First version: 30dp,
|
||||
which spread the alphabet over the whole edge.) -->
|
||||
<dimen name="flexera_wave_max_spacing">20dp</dimen>
|
||||
<!-- The stretch of edge kept for the alphabet is as tall as 26 letters and sits low: its end
|
||||
this far above the navigation bar (on top of the vertical margin). The letters that
|
||||
exist are centred in it. -->
|
||||
<dimen name="flexera_wave_bottom_offset">40dp</dimen>
|
||||
<dimen name="flexera_wave_vertical_margin">16dp</dimen>
|
||||
<!-- How far beyond the finger the wave's peak sits, so the thumb never covers the selected
|
||||
letter. A thumb hides about 11 mm (70dp) to each side of the point it touches; the
|
||||
@@ -28,11 +48,20 @@
|
||||
<dimen name="flexera_wave_finger_gap">104dp</dimen>
|
||||
<!-- The smallest bulge, used when the finger is right on the edge. -->
|
||||
<dimen name="flexera_wave_min_reach">96dp</dimen>
|
||||
<!-- How far up and down the edge the bulge spreads, at the least. A taller wave is wider
|
||||
(WaveAlphabetView.WAVE_WIDTH_SHARE_OF_REACH). -->
|
||||
<dimen name="flexera_wave_width">68dp</dimen>
|
||||
<!-- The wave's peak never comes closer than this to the opposite edge of the screen. -->
|
||||
<dimen name="flexera_wave_far_margin">36dp</dimen>
|
||||
<!-- How far up and down the edge the bulge spreads, at the least: wide, so that about five
|
||||
letters on each side of the finger ride the wave and curve around the thumb, as in
|
||||
Niagara Launcher. A taller wave is wider (WaveAlphabetView.WAVE_WIDTH_SHARE_OF_REACH). -->
|
||||
<dimen name="flexera_wave_width">104dp</dimen>
|
||||
<!-- The wave's peak never comes closer than this to the opposite edge of the screen. It
|
||||
leaves room for the bubble beyond the peak. -->
|
||||
<dimen name="flexera_wave_far_margin">66dp</dimen>
|
||||
<!-- The bubble with the selected letter: its size, how far beyond the wave's peak its
|
||||
centre sits, and how close it may come to a screen edge. -->
|
||||
<dimen name="flexera_wave_bubble_radius">20dp</dimen>
|
||||
<dimen name="flexera_wave_bubble_gap">38dp</dimen>
|
||||
<dimen name="flexera_wave_bubble_text_size">22sp</dimen>
|
||||
<dimen name="flexera_wave_bubble_edge_margin">6dp</dimen>
|
||||
<dimen name="flexera_wave_bubble_shadow_radius">6dp</dimen>
|
||||
|
||||
<!-- Swipe up and hold (PRD AD-17): a pause only counts once the swipe has travelled this far.
|
||||
Enough to tell a hold from a hesitant start, short enough that a small swipe counts. -->
|
||||
@@ -42,7 +71,14 @@
|
||||
<dimen name="flexera_drawer_text_shadow_radius">3dp</dimen>
|
||||
<dimen name="flexera_drawer_text_shadow_dy">1dp</dimen>
|
||||
|
||||
<dimen name="flexera_drawer_header_height">44dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom">6dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size">14sp</dimen>
|
||||
<!-- Picture widget: the words on a widget that has no pictures, and the room beside them. -->
|
||||
<dimen name="flexera_picture_widget_hint_text_size">13sp</dimen>
|
||||
<dimen name="flexera_picture_widget_hint_margin">10dp</dimen>
|
||||
|
||||
<dimen name="flexera_drawer_header_height">34dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom">4dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size">12sp</dimen>
|
||||
<dimen name="flexera_drawer_header_height_comfy">38dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom_comfy">5dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size_comfy">13sp</dimen>
|
||||
</resources>
|
||||
@@ -27,6 +27,46 @@
|
||||
<string name="flexera_about_built_on_title">Lawnchair</string>
|
||||
<string name="flexera_about_built_on_body">Flexera Launcher is a modified version of Lawnchair, the open-source launcher made by the Lawnchair team. Lawnchair is itself based on Launcher3 from the Android Open Source Project.</string>
|
||||
<string name="flexera_about_lawnchair_source">Lawnchair source code</string>
|
||||
<!-- Import of a Smart Launcher backup (app.flexera.backup) -->
|
||||
<string name="flexera_import_smart_launcher">Import from Smart Launcher</string>
|
||||
<string name="flexera_import_smart_launcher_description">Home screens, folders and widgets from a Smart Launcher backup file (.slbk)</string>
|
||||
<string name="flexera_import_found">In this backup</string>
|
||||
<string name="flexera_import_screens">Home screens</string>
|
||||
<string name="flexera_import_screens_value">%1$d, on a grid of %2$d by %3$d</string>
|
||||
<string name="flexera_import_apps">Apps on the home screens</string>
|
||||
<string name="flexera_import_folders">Folders</string>
|
||||
<plurals name="flexera_import_folders_value">
|
||||
<item quantity="one">%1$d, holding %2$d app</item>
|
||||
<item quantity="other">%1$d, holding %2$d apps</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_widgets">Widgets</string>
|
||||
<string name="flexera_import_widgets_value">%1$d. Each arrives in its place as “tap to set up”: Android asks you to allow it again, and its own settings start fresh.</string>
|
||||
<string name="flexera_import_photo_widgets">Photo widgets</string>
|
||||
<plurals name="flexera_import_photo_widgets_value">
|
||||
<item quantity="one">%1$d. It becomes Flexera Launcher\'s picture widget and keeps its photo. Tap it once after the import.</item>
|
||||
<item quantity="other">%1$d. They become Flexera Launcher\'s picture widgets and keep their photos. Tap each once after the import.</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_left_out">Left out</string>
|
||||
<string name="flexera_import_own_widgets">Other widgets of Smart Launcher itself</string>
|
||||
<plurals name="flexera_import_own_widgets_value">
|
||||
<item quantity="one">%1$d. It exists only inside Smart Launcher, so its place stays empty.</item>
|
||||
<item quantity="other">%1$d. They exist only inside Smart Launcher, so their places stay empty.</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_skipped">Other items</string>
|
||||
<plurals name="flexera_import_skipped_value">
|
||||
<item quantity="one">%1$d that this launcher cannot show or place</item>
|
||||
<item quantity="other">%1$d that this launcher cannot show or place</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_effect">What importing does</string>
|
||||
<string name="flexera_import_effect_title">Replaces your current home screens</string>
|
||||
<string name="flexera_import_effect_body">The home screens you have now are removed. The grid takes the size it has in the backup and the dock is switched off, because Smart Launcher has none; both can be changed again in Settings. Icons of apps that are not installed on this phone are dropped. Make a backup first if you want to be able to go back.</string>
|
||||
<string name="flexera_import_action">Import</string>
|
||||
<string name="flexera_import_done">Imported</string>
|
||||
<string name="flexera_import_failed">The import failed</string>
|
||||
<string name="flexera_import_unreadable">This is not a Smart Launcher backup that can be read</string>
|
||||
<string name="flexera_about_share">Share</string>
|
||||
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
|
||||
<string name="flexera_about_share_qr_description">QR code that opens the download page</string>
|
||||
<string name="flexera_about_privacy">Privacy</string>
|
||||
<string name="flexera_about_privacy_title">Nothing about you leaves your phone</string>
|
||||
<string name="flexera_about_privacy_body">Flexera Launcher goes online for one thing: asking its release page whether a newer version exists, about once a day, and downloading that version when you ask. It sends nothing about you, your apps or your phone.</string>
|
||||
@@ -39,6 +79,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>
|
||||
@@ -48,13 +92,31 @@
|
||||
<string name="flexera_scrub_from_home">Scrub from the home screen</string>
|
||||
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
|
||||
|
||||
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
|
||||
<!-- Its name in the list of widgets -->
|
||||
<string name="flexera_picture_widget">Pictures</string>
|
||||
<string name="flexera_picture_widget_description">One picture, or several that take turns every 10 seconds</string>
|
||||
<!-- Shown on a picture widget that has no pictures -->
|
||||
<string name="flexera_picture_widget_empty">Tap to choose pictures</string>
|
||||
<!-- Read by screen readers. %1$d is the picture being shown, %2$d is how many there are -->
|
||||
<string name="flexera_picture_widget_position">Picture %1$d of %2$d</string>
|
||||
<string name="flexera_picture_widget_unreadable">None of the chosen pictures could be read</string>
|
||||
|
||||
<!-- Updates section of the About screen (app.flexera.update.FlexeraUpdater) -->
|
||||
<string name="flexera_update_heading">Updates</string>
|
||||
<string name="flexera_update_check">Check for updates</string>
|
||||
<string name="flexera_update_check_idle">Flexera Launcher also checks on its own, once a day</string>
|
||||
<string name="flexera_update_checking">Checking for updates…</string>
|
||||
<string name="flexera_update_up_to_date">You have the latest version</string>
|
||||
<string name="flexera_update_check_failed">Couldn\'t reach the update server. Check your connection, then tap to try again.</string>
|
||||
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
|
||||
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
|
||||
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
|
||||
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
|
||||
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
|
||||
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
|
||||
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Tap to try again later.</string>
|
||||
<!-- %1$s is the error in the system\'s own words, for whoever looks for the cause -->
|
||||
<string name="flexera_update_failed_detail">Details: %1$s</string>
|
||||
<!-- %1$s is a version number such as 0.2.0 -->
|
||||
<string name="flexera_update_download">Download version %1$s</string>
|
||||
<!-- %1$s is a file size such as 24 MB -->
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Flexera Launcher's picture widget (app.flexera.widget.PictureWidgetProvider). Two cells by two
|
||||
when first placed, resizable down to one cell and up to the whole screen. The launcher draws it
|
||||
itself, so the layouts named here are never shown on the home screen; previewLayout is what
|
||||
makes the widget list ask for a live preview, which the launcher also draws.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="40dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
android:description="@string/flexera_picture_widget_description"
|
||||
android:configure="app.flexera.widget.PictureWidgetConfigActivity"
|
||||
android:initialLayout="@layout/flexera_picture_widget_placeholder"
|
||||
android:previewLayout="@layout/flexera_picture_widget_placeholder"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
@@ -0,0 +1,692 @@
|
||||
package app.flexera.backup
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import app.flexera.widget.PictureWidgetProvider
|
||||
import app.flexera.widget.PictureWidgetStore
|
||||
import app.lawnchair.DeviceProfileOverrides
|
||||
import app.lawnchair.backup.LawnchairBackup
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import com.android.launcher3.GridType
|
||||
import com.android.launcher3.InvariantDeviceProfile
|
||||
import com.android.launcher3.LauncherSettings.Favorites
|
||||
import com.android.launcher3.model.DatabaseHelper
|
||||
import com.android.launcher3.model.DeviceGridState
|
||||
import com.android.launcher3.model.ModelDbController
|
||||
import com.android.launcher3.pm.UserCache
|
||||
import com.android.launcher3.provider.RestoreDbTask
|
||||
import com.android.launcher3.shortcuts.ShortcutKey
|
||||
import com.android.launcher3.shortcuts.ShortcutRequest
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URISyntaxException
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Imports the home screens of a Smart Launcher backup: icons, folders and widgets, each in the
|
||||
* place it had, on a grid of the same size and, like Smart Launcher, without a dock.
|
||||
*
|
||||
* A backup (a .slbk file) is a zip of Smart Launcher's own files. Two of them matter here:
|
||||
* - `databases/ginlemon.flower.db`, a SQLite database. Its table `HomeItem` has one row per
|
||||
* thing on a home screen (screen, cell, size) that points either at a `Launchable`, which
|
||||
* `Action` describes with an ordinary Android intent, or at a `Widget` with its provider.
|
||||
* A folder is a launchable too; its members are the launchables that name it as their parent.
|
||||
* - the preferences file under `shared_prefs/`, where `launcher_profile_#0` holds the grid.
|
||||
*
|
||||
* The format is not documented. This follows one real backup, of Smart Launcher's data
|
||||
* version 64, and refuses a file in which it does not find these tables.
|
||||
*
|
||||
* What cannot come along is said to the owner before the import ([Info]): Smart Launcher's own
|
||||
* widgets, which exist only inside Smart Launcher, and items of other profiles. A widget that
|
||||
* does come along arrives as "tap to set up": Android has every launcher ask for its widgets
|
||||
* itself, and keeps a widget's settings with the launcher that made it.
|
||||
*
|
||||
* The layout is handed to Launcher3 the way Lawnchair's Nova import hands over its own
|
||||
* (see [app.lawnchair.backup.NovaBackupConverter]): as a database of Launcher3's own shape,
|
||||
* which Launcher3's restore code then takes in.
|
||||
*/
|
||||
class SmartLauncherBackupConverter(
|
||||
private val context: Context,
|
||||
private val uri: Uri,
|
||||
) {
|
||||
|
||||
/** What a backup holds, for the owner to see before agreeing to the import. */
|
||||
data class Info(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
val screens: Int,
|
||||
val apps: Int,
|
||||
val folders: Int,
|
||||
val appsInFolders: Int,
|
||||
val widgets: Int,
|
||||
/** Smart Launcher's photo widgets, which become the launcher's own picture widget. */
|
||||
val photoWidgets: Int,
|
||||
/** Smart Launcher's other widgets of its own, which stay behind. */
|
||||
val ownWidgets: Int,
|
||||
/** Other things that stay behind: no usable target, another profile, or no free cells. */
|
||||
val skipped: Int,
|
||||
)
|
||||
|
||||
/** Where a thing sits on the home screens, in whole cells. */
|
||||
private data class Place(val screen: Int, val x: Int, val y: Int, val width: Int, val height: Int)
|
||||
|
||||
/** An icon: an app, or a shortcut into an app ([shortcutId] with [packageName]). */
|
||||
private data class Icon(
|
||||
val title: String?,
|
||||
val intentUri: String,
|
||||
val packageName: String?,
|
||||
val shortcutId: String?,
|
||||
)
|
||||
|
||||
private sealed interface HomeThing {
|
||||
val place: Place
|
||||
}
|
||||
|
||||
private data class HomeIcon(override val place: Place, val icon: Icon) : HomeThing
|
||||
private data class HomeFolder(override val place: Place, val title: String?, val icons: List<Icon>) : HomeThing
|
||||
private data class HomeWidget(override val place: Place, val provider: String) : HomeThing
|
||||
|
||||
/** One of Smart Launcher's photo widgets, with the unpacked files of its pictures. */
|
||||
private data class HomePicture(override val place: Place, val photos: List<File>) : HomeThing
|
||||
|
||||
private data class Layout(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
val showLabels: Boolean?,
|
||||
val things: List<HomeThing>,
|
||||
val ownWidgets: Int,
|
||||
val skipped: Int,
|
||||
) {
|
||||
val info: Info
|
||||
get() = Info(
|
||||
columns = columns,
|
||||
rows = rows,
|
||||
screens = things.map { it.place.screen }.distinct().size,
|
||||
apps = things.count { it is HomeIcon },
|
||||
folders = things.count { it is HomeFolder },
|
||||
appsInFolders = things.sumOf { (it as? HomeFolder)?.icons?.size ?: 0 },
|
||||
widgets = things.count { it is HomeWidget },
|
||||
photoWidgets = things.count { it is HomePicture },
|
||||
ownWidgets = ownWidgets,
|
||||
skipped = skipped,
|
||||
)
|
||||
}
|
||||
|
||||
/** Reads the backup and says what is in it. Throws if it is not a backup this can read. */
|
||||
suspend fun parseInfo(): Info = withContext(Dispatchers.IO) {
|
||||
withUnpackedBackup { dir -> readLayout(dir).info }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the home screens with the backup's. The launcher has to be restarted afterwards
|
||||
* to show them.
|
||||
*/
|
||||
suspend fun convertAndRestore() {
|
||||
val layout = withContext(Dispatchers.IO) {
|
||||
// The backup stays unpacked to the end: the photo widgets' pictures are read from it.
|
||||
withUnpackedBackup { dir -> readLayout(dir).also(::restore) }
|
||||
}
|
||||
|
||||
// Settings that make the result match the source: no dock, because Smart Launcher has
|
||||
// none, and labels as they were. Last, so that a failed import changes nothing.
|
||||
val prefs2 = PreferenceManager2.getInstance(context)
|
||||
prefs2.isHotseatEnabled.set(false)
|
||||
layout.showLabels?.let { prefs2.showIconLabelsOnHomeScreen.set(it) }
|
||||
}
|
||||
|
||||
/** Puts the layout into the launcher's database and keeps the photo widgets' pictures. */
|
||||
private fun restore(layout: Layout) {
|
||||
val tempDir = newTempDir()
|
||||
try {
|
||||
val staged = File(tempDir, STAGED_DB)
|
||||
val shortcuts = writeLauncherDb(layout, staged)
|
||||
applyGrid(layout)
|
||||
|
||||
val restored = context.getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME)
|
||||
restored.parentFile?.mkdirs()
|
||||
staged.copyTo(restored, overwrite = true)
|
||||
// Opening the controller moves the restored file into place; the restore task
|
||||
// then marks every item as freshly restored, which is what makes Launcher3 ask
|
||||
// for the widgets anew and drop icons of apps that are not installed.
|
||||
check(RestoreDbTask.performRestore(context, ModelDbController(context))) {
|
||||
"Launcher3 did not accept the converted layout"
|
||||
}
|
||||
pinShortcuts(shortcuts)
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
keepPictures(layout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the pictures of the imported photo widgets where the picture widget finds them. Each
|
||||
* widget arrives as "tap to set up" like every imported widget; the tap then gives it these
|
||||
* pictures in place of asking for some (PictureWidgetConfigActivity).
|
||||
*/
|
||||
private fun keepPictures(layout: Layout) {
|
||||
PictureWidgetStore.clearImports(context)
|
||||
for (thing in layout.things) {
|
||||
if (thing is HomePicture && thing.photos.isNotEmpty()) {
|
||||
PictureWidgetStore.stageImport(
|
||||
context,
|
||||
thing.place.screen,
|
||||
thing.place.x,
|
||||
thing.place.y,
|
||||
thing.photos.map(Uri::fromFile),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reading the backup ------------------------------------------------------------------
|
||||
|
||||
private fun <T> withUnpackedBackup(block: (File) -> T): T {
|
||||
val dir = newTempDir()
|
||||
try {
|
||||
unpack(dir)
|
||||
return block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newTempDir(): File =
|
||||
File(context.cacheDir, "$TEMP_DIR_PREFIX${UUID.randomUUID()}").apply { mkdirs() }
|
||||
|
||||
/**
|
||||
* Unpacks the database, the preference files and the pictures of the photo widgets, and
|
||||
* nothing else, into [dir]. Entry names come from the file and are not trusted: only the
|
||||
* known places are accepted, each file lands under a name made here, and a file of
|
||||
* unreasonable size ends the import.
|
||||
*/
|
||||
private fun unpack(dir: File) {
|
||||
val descriptor = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
?: error("Cannot open the backup file")
|
||||
descriptor.use {
|
||||
ZipInputStream(FileInputStream(it.fileDescriptor)).use { zip ->
|
||||
var photos = 0
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name
|
||||
val photo = SL_PHOTO_ENTRY.matchEntire(name)
|
||||
val target = when {
|
||||
entry.isDirectory -> null
|
||||
name == SL_DB_ENTRY -> File(dir, SL_DB)
|
||||
name.startsWith(SL_PREFS_DIR) && name.endsWith(".xml") ->
|
||||
File(dir, PREFS_PREFIX + File(name).name)
|
||||
// A photo widget's picture, kept under the number of its widget.
|
||||
photo != null && photos < MAX_PHOTOS ->
|
||||
File(dir, photoName(photo.groupValues[1], photos++))
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
require(target.canonicalPath.startsWith(dir.canonicalPath + File.separator)) {
|
||||
"Backup entry outside the import folder: $name"
|
||||
}
|
||||
FileOutputStream(target).use { out ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = zip.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
require(total <= MAX_ENTRY_BYTES) { "Backup entry too large: $name" }
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLayout(dir: File): Layout {
|
||||
val dbFile = File(dir, SL_DB)
|
||||
require(dbFile.exists()) { "No Smart Launcher database in this file" }
|
||||
val grid = readGrid(dir)
|
||||
|
||||
SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
for (table in listOf(T_HOME, T_LAUNCHABLE, T_ACTION, T_WIDGET)) {
|
||||
require(tableExists(db, table)) { "Smart Launcher database without table $table" }
|
||||
}
|
||||
return readHomeScreens(db, grid, dir)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Grid(val columns: Int, val rows: Int, val showLabels: Boolean?)
|
||||
|
||||
/** The home grid from the preferences, or null where a backup does not state it. */
|
||||
private fun readGrid(dir: File): Grid? {
|
||||
val files = dir.listFiles { file -> file.name.startsWith(PREFS_PREFIX) } ?: return null
|
||||
for (file in files) {
|
||||
try {
|
||||
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
|
||||
val strings = document.documentElement.getElementsByTagName("string")
|
||||
for (i in 0 until strings.length) {
|
||||
val node = strings.item(i)
|
||||
val name = node.attributes?.getNamedItem("name")?.nodeValue ?: continue
|
||||
if (!name.startsWith(SL_PROFILE_KEY_PREFIX)) continue
|
||||
val settings = JSONObject(node.textContent).optJSONObject("gridUserSettings") ?: continue
|
||||
val columns = settings.optInt("columns", 0)
|
||||
val rows = settings.optInt("rows", 0)
|
||||
if (columns in 1..MAX_GRID && rows in 1..MAX_GRID) {
|
||||
val labels = if (settings.has("hasLabel")) settings.optBoolean("hasLabel") else null
|
||||
return Grid(columns, rows, labels)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unreadable preference file in backup: ${file.name}", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?, dir: File): Layout {
|
||||
data class Row(
|
||||
val launchable: Long?,
|
||||
val widget: Long?,
|
||||
val screen: Int,
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
// Smart Launcher can keep more than one arrangement ("configId"); the first is the one
|
||||
// in use on a phone held upright.
|
||||
val rows = ArrayList<Row>()
|
||||
db.rawQuery(
|
||||
"SELECT idLaunchable, idWidget, screen, cellX, cellY, spanX, spanY FROM $T_HOME " +
|
||||
"WHERE configId = (SELECT MIN(configId) FROM $T_HOME) ORDER BY zIndex, id",
|
||||
null,
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
rows.add(
|
||||
Row(
|
||||
launchable = if (c.isNull(0)) null else c.getLong(0),
|
||||
widget = if (c.isNull(1)) null else c.getLong(1),
|
||||
screen = c.getInt(2),
|
||||
x = c.getDouble(3).roundToInt(),
|
||||
y = c.getDouble(4).roundToInt(),
|
||||
width = c.getDouble(5).roundToInt().coerceAtLeast(1),
|
||||
height = c.getDouble(6).roundToInt().coerceAtLeast(1),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val columns = grid?.columns ?: max(MIN_GRID, rows.maxOfOrNull { it.x + it.width } ?: 0)
|
||||
val gridRows = grid?.rows ?: max(MIN_GRID, rows.maxOfOrNull { it.y + it.height } ?: 0)
|
||||
// Launcher3 numbers its screens from 0 without gaps, left to right.
|
||||
val screenIndex = rows.map { it.screen }.distinct().sorted().withIndex().associate { it.value to it.index }
|
||||
val taken = HashSet<Triple<Int, Int, Int>>()
|
||||
|
||||
val things = ArrayList<HomeThing>()
|
||||
var ownWidgets = 0
|
||||
var skipped = 0
|
||||
for (row in rows) {
|
||||
val screen = screenIndex.getValue(row.screen)
|
||||
val place = Place(screen, row.x, row.y, row.width, row.height)
|
||||
val cells = cellsOf(place)
|
||||
// Launcher3 keeps one thing per cell and nothing outside the grid.
|
||||
val fits = row.x >= 0 && row.y >= 0 && row.x + row.width <= columns &&
|
||||
row.y + row.height <= gridRows && cells.none { it in taken }
|
||||
|
||||
val read: HomeThing? = when {
|
||||
row.widget != null -> readWidget(db, row.widget, place, dir)
|
||||
row.launchable != null -> readLaunchable(db, row.launchable, place)
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
// The only widgets with no counterpart here are Smart Launcher's own.
|
||||
read == null && row.widget != null -> ownWidgets++
|
||||
read == null || !fits -> skipped++
|
||||
else -> {
|
||||
things.add(read)
|
||||
taken.addAll(cells)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Layout(columns, gridRows, grid?.showLabels, things, ownWidgets, skipped)
|
||||
}
|
||||
|
||||
private fun cellsOf(place: Place): List<Triple<Int, Int, Int>> = buildList {
|
||||
for (x in place.x until place.x + place.width) {
|
||||
for (y in place.y until place.y + place.height) {
|
||||
add(Triple(place.screen, x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a widget of the backup becomes. An ordinary app widget keeps its provider. Smart
|
||||
* Launcher's photo widget becomes the launcher's own picture widget, with the pictures the
|
||||
* backup holds for it. Smart Launcher's other widgets have no counterpart: null.
|
||||
*/
|
||||
private fun readWidget(db: SQLiteDatabase, id: Long, place: Place, dir: File): HomeThing? =
|
||||
db.rawQuery("SELECT type, provider, userId FROM $T_WIDGET WHERE id = ?", arrayOf(id.toString())).use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
null
|
||||
} else {
|
||||
val type = c.getInt(0)
|
||||
val provider = if (c.isNull(1)) null else c.getString(1)
|
||||
val user = if (c.isNull(2)) OWNER_USER else c.getInt(2)
|
||||
val component = provider?.let(ComponentName::unflattenFromString)
|
||||
when {
|
||||
type == SL_WIDGET_TYPE_APP && component != null && user == OWNER_USER ->
|
||||
HomeWidget(place, component.flattenToString())
|
||||
|
||||
type != SL_WIDGET_TYPE_APP && provider?.endsWith(SL_PHOTO_WIDGET_CLASS) == true -> {
|
||||
val prefix = photoName(id.toString(), null)
|
||||
val photos = dir.listFiles { file -> file.name.startsWith(prefix) }
|
||||
HomePicture(place, photos?.sortedBy { it.name }.orEmpty())
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon or folder behind a launchable, or null if it has no target this can use. */
|
||||
private fun readLaunchable(db: SQLiteDatabase, id: Long, place: Place): HomeThing? {
|
||||
val action = readAction(db, id) ?: return null
|
||||
if (action.type != SL_ACTION_TYPE_FOLDER) {
|
||||
return action.toIcon()?.let { HomeIcon(place, it) }
|
||||
}
|
||||
val icons = ArrayList<Icon>()
|
||||
db.rawQuery(
|
||||
"SELECT id FROM $T_LAUNCHABLE WHERE idParentFolderLaunchable = ? ORDER BY position, id",
|
||||
arrayOf(id.toString()),
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
readAction(db, c.getLong(0))?.takeIf { it.type != SL_ACTION_TYPE_FOLDER }?.toIcon()?.let(icons::add)
|
||||
}
|
||||
}
|
||||
// Launcher3 has no use for a folder with nothing in it.
|
||||
return if (icons.isEmpty()) null else HomeFolder(place, action.label, icons)
|
||||
}
|
||||
|
||||
private data class Action(
|
||||
val type: Int,
|
||||
val intentUri: String?,
|
||||
val user: Int,
|
||||
val label: String?,
|
||||
val shortcutId: String?,
|
||||
) {
|
||||
/** Launcher3's form of this action, if it opens an app or one of an app's shortcuts. */
|
||||
fun toIcon(): Icon? {
|
||||
// Icons of a work profile or a second user are left out; -1 means "no user", as on folders.
|
||||
if (user != OWNER_USER && user != NO_USER) return null
|
||||
val intent = try {
|
||||
Intent.parseUri(intentUri ?: return null, 0)
|
||||
} catch (_: URISyntaxException) {
|
||||
return null
|
||||
}
|
||||
val component = intent.component
|
||||
val packageName = component?.packageName ?: intent.`package`
|
||||
return when {
|
||||
!shortcutId.isNullOrEmpty() && packageName != null ->
|
||||
Icon(label, ShortcutKey.makeIntent(shortcutId, packageName).toUri(0), packageName, shortcutId)
|
||||
|
||||
component != null -> Icon(label, launchIntentFor(component).toUri(0), packageName, null)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The first action of a launchable, which is what a tap does. */
|
||||
private fun readAction(db: SQLiteDatabase, launchable: Long): Action? =
|
||||
db.rawQuery(
|
||||
"SELECT type, intentUri, userId, label, deepShortcutId FROM $T_ACTION " +
|
||||
"WHERE idLaunchable = ? ORDER BY actionId LIMIT 1",
|
||||
arrayOf(launchable.toString()),
|
||||
).use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
null
|
||||
} else {
|
||||
Action(
|
||||
type = c.getInt(0),
|
||||
intentUri = if (c.isNull(1)) null else c.getString(1),
|
||||
user = if (c.isNull(2)) OWNER_USER else c.getInt(2),
|
||||
label = if (c.isNull(3)) null else c.getString(3),
|
||||
shortcutId = if (c.isNull(4)) null else c.getString(4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableExists(db: SQLiteDatabase, table: String): Boolean =
|
||||
db.rawQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", arrayOf(table)).use {
|
||||
it.moveToFirst()
|
||||
}
|
||||
|
||||
// ---- Writing Launcher3's layout ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Writes [layout] as a Launcher3 database at [target]. Returns the app shortcuts in it, by
|
||||
* package, which the launcher has to pin before it may show them.
|
||||
*/
|
||||
private fun writeLauncherDb(layout: Layout, target: File): Map<String, Set<String>> {
|
||||
val profileId = UserCache.INSTANCE.get(context).getSerialNumberForUser(Process.myUserHandle())
|
||||
val shortcuts = HashMap<String, MutableSet<String>>()
|
||||
val now = System.currentTimeMillis()
|
||||
var nextId = 1
|
||||
|
||||
SQLiteDatabase.openOrCreateDatabase(target, null).use { db ->
|
||||
db.version = DatabaseHelper.SCHEMA_VERSION
|
||||
Favorites.addTableToDb(db, profileId, false)
|
||||
|
||||
fun insert(
|
||||
container: Int,
|
||||
place: Place?,
|
||||
rank: Int,
|
||||
itemType: Int,
|
||||
title: String? = null,
|
||||
intentUri: String? = null,
|
||||
provider: String? = null,
|
||||
): Int {
|
||||
val id = nextId++
|
||||
db.insertOrThrow(
|
||||
Favorites.TABLE_NAME,
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put(Favorites._ID, id)
|
||||
put(Favorites.TITLE, title)
|
||||
put(Favorites.INTENT, intentUri)
|
||||
put(Favorites.CONTAINER, container)
|
||||
put(Favorites.SCREEN, place?.screen ?: 0)
|
||||
put(Favorites.CELLX, place?.x ?: 0)
|
||||
put(Favorites.CELLY, place?.y ?: 0)
|
||||
put(Favorites.SPANX, place?.width ?: 1)
|
||||
put(Favorites.SPANY, place?.height ?: 1)
|
||||
put(Favorites.ITEM_TYPE, itemType)
|
||||
put(Favorites.APPWIDGET_ID, -1)
|
||||
put(Favorites.APPWIDGET_PROVIDER, provider)
|
||||
put(Favorites.MODIFIED, now)
|
||||
put(Favorites.RESTORED, 0)
|
||||
put(Favorites.PROFILE_ID, profileId)
|
||||
put(Favorites.RANK, rank)
|
||||
put(Favorites.OPTIONS, 0)
|
||||
put(Favorites.APPWIDGET_SOURCE, -1)
|
||||
},
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
fun insertIcon(icon: Icon, container: Int, place: Place?, rank: Int) {
|
||||
val isShortcut = icon.shortcutId != null && icon.packageName != null
|
||||
if (isShortcut) {
|
||||
shortcuts.getOrPut(icon.packageName!!) { HashSet() }.add(icon.shortcutId!!)
|
||||
}
|
||||
insert(
|
||||
container = container,
|
||||
place = place,
|
||||
rank = rank,
|
||||
itemType = if (isShortcut) Favorites.ITEM_TYPE_DEEP_SHORTCUT else Favorites.ITEM_TYPE_APPLICATION,
|
||||
title = icon.title,
|
||||
intentUri = icon.intentUri,
|
||||
)
|
||||
}
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (thing in layout.things) {
|
||||
when (thing) {
|
||||
is HomeIcon -> insertIcon(thing.icon, Favorites.CONTAINER_DESKTOP, thing.place, 0)
|
||||
|
||||
is HomeFolder -> {
|
||||
val folder = insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_FOLDER,
|
||||
title = thing.title,
|
||||
)
|
||||
thing.icons.forEachIndexed { rank, icon -> insertIcon(icon, folder, null, rank) }
|
||||
}
|
||||
|
||||
is HomeWidget -> insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_APPWIDGET,
|
||||
provider = thing.provider,
|
||||
)
|
||||
|
||||
is HomePicture -> insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_APPWIDGET,
|
||||
provider = PictureWidgetProvider.componentName.flattenToString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
return shortcuts
|
||||
}
|
||||
|
||||
/** Makes the home grid the size of the backup's, in every place the launcher keeps it. */
|
||||
private fun applyGrid(layout: Layout) {
|
||||
val prefs = PreferenceManager.getInstance(context)
|
||||
val hotseat = prefs.hotseatColumns.get()
|
||||
val gridInfo = DeviceProfileOverrides.DBGridInfo(
|
||||
numHotseatColumns = hotseat,
|
||||
numRows = layout.rows,
|
||||
numColumns = layout.columns,
|
||||
)
|
||||
val gridState = DeviceGridState(
|
||||
layout.columns,
|
||||
layout.rows,
|
||||
hotseat,
|
||||
InvariantDeviceProfile.TYPE_PHONE,
|
||||
gridInfo.dbFile,
|
||||
GridType.GRID_TYPE_ANY,
|
||||
)
|
||||
gridState.writeToPrefs(context, true)
|
||||
gridState.writeToPrefs(context)
|
||||
InvariantDeviceProfile.INSTANCE.get(context).dbFile = gridInfo.dbFile
|
||||
prefs.sp.edit()
|
||||
.putInt(prefs.workspaceColumns.key, layout.columns)
|
||||
.putInt(prefs.workspaceRows.key, layout.rows)
|
||||
.commit()
|
||||
}
|
||||
|
||||
/** Pins the imported app shortcuts, keeping whatever the launcher has pinned already. */
|
||||
private fun pinShortcuts(shortcuts: Map<String, Set<String>>) {
|
||||
if (shortcuts.isEmpty()) return
|
||||
val launcherApps = context.getSystemService(LauncherApps::class.java) ?: return
|
||||
val user = Process.myUserHandle()
|
||||
for ((packageName, ids) in shortcuts) {
|
||||
val pinned = LinkedHashSet<String>()
|
||||
val current = ShortcutRequest(context, user)
|
||||
.forPackage(packageName)
|
||||
.query(ShortcutRequest.PINNED or LauncherApps.ShortcutQuery.FLAG_GET_KEY_FIELDS_ONLY)
|
||||
if (current.wasSuccess()) current.mapTo(pinned) { it.id }
|
||||
pinned.addAll(ids)
|
||||
try {
|
||||
launcherApps.pinShortcuts(packageName, pinned.toList(), user)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not pin the imported shortcuts of $packageName", e)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "Could not pin the imported shortcuts of $packageName", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SmartLauncherImport"
|
||||
|
||||
const val SL_DB_ENTRY = "databases/ginlemon.flower.db"
|
||||
const val SL_PREFS_DIR = "shared_prefs/"
|
||||
const val SL_PROFILE_KEY_PREFIX = "launcher_profile_"
|
||||
const val T_HOME = "HomeItem"
|
||||
const val T_LAUNCHABLE = "Launchable"
|
||||
const val T_ACTION = "Action"
|
||||
const val T_WIDGET = "Widget"
|
||||
|
||||
/** `Action.type` of a folder; every other type seen opens an app. */
|
||||
const val SL_ACTION_TYPE_FOLDER = 9
|
||||
|
||||
/** `Widget.type` of an ordinary app widget; the other types are Smart Launcher's own. */
|
||||
const val SL_WIDGET_TYPE_APP = 1
|
||||
|
||||
/** How `Widget.provider` ends for Smart Launcher's photo widget, in every edition of it. */
|
||||
const val SL_PHOTO_WIDGET_CLASS = ".PhotoWidget"
|
||||
|
||||
/** Where a backup keeps the pictures of a photo widget: a folder named by `Widget.id`. */
|
||||
val SL_PHOTO_ENTRY = Regex("files/widgetPhoto/(\\d{1,18})/[^/]+")
|
||||
|
||||
/** More pictures than this in one backup are not taken along. */
|
||||
const val MAX_PHOTOS = 100
|
||||
|
||||
/**
|
||||
* The name an unpacked picture gets: its widget's number, then its turn. Without a turn,
|
||||
* the start of the names of all pictures of that widget.
|
||||
*/
|
||||
fun photoName(widget: String, turn: Int?): String = "photo_${widget}_" + (turn?.toString() ?: "")
|
||||
|
||||
/** Smart Launcher's user ids: the phone's owner, and "no user" on folders. */
|
||||
const val OWNER_USER = 0
|
||||
const val NO_USER = -1
|
||||
|
||||
const val TEMP_DIR_PREFIX = "smartlauncher_"
|
||||
const val SL_DB = "smartlauncher.db"
|
||||
const val PREFS_PREFIX = "prefs_"
|
||||
const val STAGED_DB = "workspace.db"
|
||||
|
||||
/** Far above any real backup's database; a larger entry is not a backup. */
|
||||
const val MAX_ENTRY_BYTES = 64L * 1024 * 1024
|
||||
|
||||
const val MIN_GRID = 4
|
||||
const val MAX_GRID = 20
|
||||
|
||||
/** The intent Launcher3 itself stores for an app icon. */
|
||||
fun launchIntentFor(component: ComponentName): Intent =
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(component)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package app.flexera.backup
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import app.flexera.backup.SmartLauncherImportViewModel.Event
|
||||
import app.flexera.backup.SmartLauncherImportViewModel.State
|
||||
import app.lawnchair.ui.preferences.LocalIsExpandedScreen
|
||||
import app.lawnchair.ui.preferences.LocalNavController
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
|
||||
import app.lawnchair.ui.preferences.navigation.ImportSmartLauncherBackup
|
||||
import app.lawnchair.util.BackHandler
|
||||
import app.lawnchair.util.restartLauncher
|
||||
import com.android.launcher3.R
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* The screen between choosing a Smart Launcher backup and importing it. It says what the file
|
||||
* holds, what will not come along and what the import replaces, and waits for the owner's word.
|
||||
*/
|
||||
fun NavGraphBuilder.smartLauncherImportGraph() {
|
||||
composable<ImportSmartLauncherBackup> { entry ->
|
||||
val route: ImportSmartLauncherBackup = entry.toRoute()
|
||||
val backup = remember { String(Base64.getDecoder().decode(route.base64Uri)).toUri() }
|
||||
val viewModel: SmartLauncherImportViewModel = viewModel()
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.open(backup)
|
||||
onDispose { }
|
||||
}
|
||||
SmartLauncherImportScreen(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmartLauncherImportScreen(viewModel: SmartLauncherImportViewModel) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
Event.Imported -> {
|
||||
Toast.makeText(context, R.string.flexera_import_done, Toast.LENGTH_SHORT).show()
|
||||
restartLauncher(context)
|
||||
}
|
||||
|
||||
Event.Failed -> Toast.makeText(context, R.string.flexera_import_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PreferenceLayout(
|
||||
label = stringResource(id = R.string.flexera_import_smart_launcher),
|
||||
backArrowVisible = !LocalIsExpandedScreen.current,
|
||||
) {
|
||||
when (val current = state) {
|
||||
State.Reading -> Box(modifier = Modifier.fillMaxSize()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
State.Unreadable -> {
|
||||
val back = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
LaunchedEffect(Unit) {
|
||||
Toast.makeText(context, R.string.flexera_import_unreadable, Toast.LENGTH_LONG).show()
|
||||
back?.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
is State.Ready -> {
|
||||
// Leaving in the middle of an import would leave the home screens half replaced.
|
||||
if (current.importing) BackHandler {}
|
||||
BackupContents(current.info)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = viewModel::import,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !current.importing,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.flexera_import_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupContents(info: SmartLauncherBackupConverter.Info) {
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_found)) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_screens,
|
||||
stringResource(id = R.string.flexera_import_screens_value, info.screens, info.columns, info.rows),
|
||||
)
|
||||
if (info.apps > 0) InfoRow(R.string.flexera_import_apps, info.apps.toString())
|
||||
if (info.folders > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_folders,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_folders_value,
|
||||
count = info.appsInFolders,
|
||||
info.folders,
|
||||
info.appsInFolders,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (info.widgets > 0) {
|
||||
InfoRow(R.string.flexera_import_widgets, stringResource(id = R.string.flexera_import_widgets_value, info.widgets))
|
||||
}
|
||||
if (info.photoWidgets > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_photo_widgets,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_photo_widgets_value,
|
||||
count = info.photoWidgets,
|
||||
info.photoWidgets,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.ownWidgets > 0 || info.skipped > 0) {
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_left_out)) {
|
||||
if (info.ownWidgets > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_own_widgets,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_own_widgets_value,
|
||||
count = info.ownWidgets,
|
||||
info.ownWidgets,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (info.skipped > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_skipped,
|
||||
pluralStringResource(id = R.plurals.flexera_import_skipped_value, count = info.skipped, info.skipped),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_effect)) {
|
||||
PreferenceTemplate(
|
||||
title = { Text(text = stringResource(id = R.string.flexera_import_effect_title)) },
|
||||
description = { Text(text = stringResource(id = R.string.flexera_import_effect_body)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(labelRes: Int, value: String) {
|
||||
PreferenceTemplate(
|
||||
title = { Text(text = stringResource(id = labelRes)) },
|
||||
description = { Text(text = value) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns an action that lets the owner pick a backup file and then opens the import screen for it. */
|
||||
@Composable
|
||||
fun smartLauncherImportOpener(): () -> Unit {
|
||||
val navController = LocalNavController.current
|
||||
val request = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
|
||||
val base64Uri = Base64.getEncoder().encodeToString(uri.toString().toByteArray())
|
||||
navController.navigate(ImportSmartLauncherBackup(base64Uri))
|
||||
}
|
||||
return {
|
||||
// A .slbk file has no file type Android knows, so every file is offered.
|
||||
request.launch(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("*/*"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app.flexera.backup
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Holds one Smart Launcher import: reads the chosen backup, shows what is in it, and on the
|
||||
* owner's word replaces the home screens with it. Same shape as Lawnchair's view model for a
|
||||
* Nova backup.
|
||||
*/
|
||||
internal class SmartLauncherImportViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
sealed interface Event {
|
||||
data object Imported : Event
|
||||
data object Failed : Event
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
data object Reading : State
|
||||
data object Unreadable : State
|
||||
data class Ready(val info: SmartLauncherBackupConverter.Info, val importing: Boolean = false) : State
|
||||
}
|
||||
|
||||
private var converter: SmartLauncherBackupConverter? = null
|
||||
|
||||
private val mutableState = MutableStateFlow<State>(State.Reading)
|
||||
val state: StateFlow<State> = mutableState.asStateFlow()
|
||||
|
||||
private val eventChannel = Channel<Event>(Channel.BUFFERED)
|
||||
val events = eventChannel.receiveAsFlow()
|
||||
|
||||
fun open(backup: Uri) {
|
||||
if (converter != null) return
|
||||
val converter = SmartLauncherBackupConverter(getApplication(), backup).also { converter = it }
|
||||
viewModelScope.launch {
|
||||
mutableState.value = try {
|
||||
State.Ready(converter.parseInfo())
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Not a Smart Launcher backup this can read", t)
|
||||
State.Unreadable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import() {
|
||||
val ready = mutableState.value as? State.Ready ?: return
|
||||
val converter = converter ?: return
|
||||
if (ready.importing) return
|
||||
viewModelScope.launch {
|
||||
mutableState.value = ready.copy(importing = true)
|
||||
try {
|
||||
converter.convertAndRestore()
|
||||
eventChannel.send(Event.Imported)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Import failed", t)
|
||||
mutableState.value = ready.copy(importing = false)
|
||||
eventChannel.send(Event.Failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SmartLauncherImport"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ import com.android.launcher3.BubbleTextView
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.allapps.ActivityAllAppsContainerView
|
||||
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.android.launcher3.views.BaseDragLayer
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
|
||||
@@ -44,9 +47,54 @@ object FlexeraDrawer {
|
||||
@JvmStatic
|
||||
fun sectionHeader(sectionName: String): AdapterItem = SectionHeaderItem(sectionName)
|
||||
|
||||
/** The sizes the owner can give the list's rows (Settings, App drawer). */
|
||||
enum class RowSize(val key: String) { COMPACT("compact"), COMFY("comfy") }
|
||||
|
||||
/** The row size in use. Changing the setting recreates the launcher. */
|
||||
@JvmStatic
|
||||
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View =
|
||||
inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false).also(::applySidePadding)
|
||||
fun rowSize(context: Context): RowSize {
|
||||
val key = PreferenceManager.getInstance(context).flexeraDrawerRowSize.get()
|
||||
return RowSize.entries.firstOrNull { it.key == key } ?: RowSize.COMPACT
|
||||
}
|
||||
|
||||
/** Picks the dimension of the row size in use. */
|
||||
private fun sized(context: Context, compact: Int, comfy: Int): Int =
|
||||
if (rowSize(context) == RowSize.COMFY) comfy else compact
|
||||
|
||||
/** How tall a letter header is. */
|
||||
@JvmStatic
|
||||
fun headerHeight(context: Context): Int = context.resources.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_header_height, R.dimen.flexera_drawer_header_height_comfy),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View {
|
||||
val header = inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false) as TextView
|
||||
val context = header.context
|
||||
val res = context.resources
|
||||
// The layout carries the compact sizes; the row size in use has the last word.
|
||||
header.layoutParams.height = headerHeight(context)
|
||||
header.setTextSize(
|
||||
TypedValue.COMPLEX_UNIT_PX,
|
||||
res.getDimension(
|
||||
sized(context, R.dimen.flexera_drawer_header_text_size, R.dimen.flexera_drawer_header_text_size_comfy),
|
||||
),
|
||||
)
|
||||
header.setPadding(
|
||||
header.paddingLeft,
|
||||
header.paddingTop,
|
||||
header.paddingRight,
|
||||
res.getDimensionPixelSize(
|
||||
sized(
|
||||
context,
|
||||
R.dimen.flexera_drawer_header_padding_bottom,
|
||||
R.dimen.flexera_drawer_header_padding_bottom_comfy,
|
||||
),
|
||||
),
|
||||
)
|
||||
applySidePadding(header)
|
||||
return header
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the owner has moved the wave alphabet to the left edge (PRD AD-15). Changing the
|
||||
@@ -90,9 +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)
|
||||
icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height)
|
||||
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
|
||||
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.flexera_drawer_row_text_size))
|
||||
val context = icon.context
|
||||
icon.layoutParams.height = rowHeight(context)
|
||||
icon.setIconSizeOverride(
|
||||
res.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_row_icon_size, R.dimen.flexera_drawer_row_icon_size_comfy),
|
||||
),
|
||||
)
|
||||
icon.compoundDrawablePadding = res.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_row_icon_gap, R.dimen.flexera_drawer_row_icon_gap_comfy),
|
||||
)
|
||||
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, rowTextSize(context))
|
||||
applySidePadding(icon)
|
||||
// The drawer has no background, so names sit directly on the dimmed wallpaper: white,
|
||||
// with a soft shadow to stay readable over light areas.
|
||||
@@ -108,12 +164,89 @@ object FlexeraDrawer {
|
||||
/** Shadow behind white drawer text. */
|
||||
const val TEXT_SHADOW_COLOR = 0x73000000
|
||||
|
||||
/**
|
||||
* How tall an app row is. The row has to hold the name with a notification preview under
|
||||
* it (PRD AD-11). At the usual font size the set height does; with a large system font
|
||||
* the row grows instead.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun rowHeight(context: Context): Int = max(
|
||||
context.resources.getDimensionPixelSize(
|
||||
sized(context, R.dimen.flexera_drawer_row_height, R.dimen.flexera_drawer_row_height_comfy),
|
||||
),
|
||||
(rowTextSize(context) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
|
||||
)
|
||||
|
||||
/** The size of an app name in a row, in pixels. */
|
||||
private fun rowTextSize(context: Context): Float = context.resources.getDimension(
|
||||
sized(context, R.dimen.flexera_drawer_row_text_size, R.dimen.flexera_drawer_row_text_size_comfy),
|
||||
)
|
||||
|
||||
/** The least a row's height may be, in name text sizes: two lines of text and some air. */
|
||||
private const val ROW_HEIGHT_IN_TEXT_SIZES = 2.4f
|
||||
|
||||
/**
|
||||
* What lies between the open drawer and the blurred wallpaper: black at 78%, whatever the
|
||||
* theme and the wallpaper. Lawnchair's backdrop is a grey at 40%, which over a light
|
||||
* wallpaper leaves a light grey behind the drawer's white names.
|
||||
*/
|
||||
const val BACKDROP_COLOR = 0xC7000000.toInt()
|
||||
|
||||
/**
|
||||
* Lawnchair fades the search field's pill out while the owner types, leaving the text on the
|
||||
* drawer's panel. This drawer has no panel, so the pill stays.
|
||||
*/
|
||||
const val KEEP_SEARCH_FIELD_BACKGROUND = true
|
||||
|
||||
/**
|
||||
* Moves the search field up with the list, so that it scrolls away like the list's first
|
||||
* row and comes back when the list is at its top. The field is not a row of the list: it
|
||||
* lies over the list's top padding and is shifted by as much as the list has scrolled.
|
||||
* While a search is on, and where the drawer does not use this (work tabs, a hidden or a
|
||||
* floating search field), the field stays in place.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun moveSearchFieldWithList(appsView: ActivityAllAppsContainerView<*>) {
|
||||
val field = appsView.searchView ?: return
|
||||
val list = appsView.activeRecyclerView
|
||||
val scrolled = if (list == null || appsView.isSearching || !appsView.flexeraSearchFieldScrolls()) {
|
||||
0
|
||||
} else {
|
||||
scrolledFromTop(list)
|
||||
}
|
||||
// The field and the list's rows vanish at the same line, the top of the drawer's
|
||||
// content, which is the lower edge of the status bar. The rows are cut there by the
|
||||
// list's own top edge; the field is cut here, and travels no further than it takes to
|
||||
// be all above that line.
|
||||
val line = appsView.paddingTop
|
||||
val moved = min(scrolled, max(0, field.bottom - line))
|
||||
field.translationY = -moved.toFloat()
|
||||
val hidden = line - (field.top - moved)
|
||||
if (hidden > 0) {
|
||||
// The view copies the rectangle, so one scratch rectangle serves every call.
|
||||
fieldClip.set(0, hidden, field.width, max(hidden, field.height))
|
||||
field.clipBounds = fieldClip
|
||||
} else {
|
||||
field.clipBounds = null
|
||||
}
|
||||
}
|
||||
|
||||
private val fieldClip = Rect()
|
||||
|
||||
/**
|
||||
* How far [list] has scrolled from its top, in pixels, for as long as its first row is
|
||||
* still attached; after that, simply "far". Counting rows is exact, which the list's own
|
||||
* scroll offset, an estimate from row heights, is not.
|
||||
*/
|
||||
private fun scrolledFromTop(list: RecyclerView): Int {
|
||||
val first = list.getChildAt(0) ?: return 0
|
||||
return if (list.getChildAdapterPosition(first) == 0) {
|
||||
max(0, list.paddingTop - first.top)
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the drawer panel sits for a given all-apps progress (0 = open, 1 = closed). Launcher3
|
||||
* slides the panel up from the bottom; here it stays in place and fades (see
|
||||
@@ -227,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +379,12 @@ object FlexeraDrawer {
|
||||
return offset
|
||||
}
|
||||
|
||||
/**
|
||||
* A scrub that started from a swipe and ends sooner than this was not meant as one: nobody
|
||||
* reads a letter and means it in a fifth of a second. The swipe opens the plain list.
|
||||
*/
|
||||
const val MIN_SCRUB_MS = 200L
|
||||
|
||||
/** Ends a scrub started by [scrubFromSwipe]; the drawer stays open at the chosen letter. */
|
||||
@JvmStatic
|
||||
fun endScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
|
||||
@@ -253,8 +392,8 @@ object FlexeraDrawer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a scrub started by [scrubFromSwipe] and undoes it: the list goes back to where it
|
||||
* stood, so the drawer opens the way an ordinary swipe up leaves it.
|
||||
* Ends a scrub started by [scrubFromSwipe] and undoes it, so the drawer opens the way an
|
||||
* ordinary swipe up leaves it.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancelScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.flexera.drawer
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
@@ -16,7 +17,9 @@ import kotlin.math.abs
|
||||
* touch. Nothing is drawn on the home screen; the letters appear with the drawer.
|
||||
*
|
||||
* The edge is shared with page swipes, taps and item drags, so this claims a touch only once it
|
||||
* has clearly moved up or down, and never while an item is being dragged. It is off by default.
|
||||
* has clearly moved up or down, and never while an item is being dragged. A touch that lifts
|
||||
* again within [FlexeraDrawer.MIN_SCRUB_MS] was a flick, not a scrub, and opens the plain list.
|
||||
* The setting is on unless the owner switches it off.
|
||||
*/
|
||||
class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchController {
|
||||
|
||||
@@ -26,6 +29,7 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
/** The current touch started on the edge and may still turn into a scrub. */
|
||||
private var candidate = false
|
||||
private var scrubbing = false
|
||||
private var scrubStartTime = 0L
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
|
||||
@@ -48,7 +52,11 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
dy > touchSlop && dy > dx * VERTICAL_BIAS -> {
|
||||
candidate = false
|
||||
scrubbing = true
|
||||
launcher.stateManager.goToState(LauncherState.ALL_APPS)
|
||||
scrubStartTime = SystemClock.uptimeMillis()
|
||||
// The drawer starts opening and the alphabet takes the finger in the same
|
||||
// moment. Opening comes first, because its start is when the drawer
|
||||
// puts its list in place, which must not undo the scrub's first jump.
|
||||
openDrawer()
|
||||
FlexeraDrawer.scrubFromSwipe(launcher.appsView, launcher.dragLayer, ev)
|
||||
return true
|
||||
}
|
||||
@@ -68,12 +76,28 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
scrubbing = false
|
||||
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
|
||||
if (SystemClock.uptimeMillis() - scrubStartTime < FlexeraDrawer.MIN_SCRUB_MS) {
|
||||
// A flick up that happened to start on the edge: open the plain list.
|
||||
FlexeraDrawer.cancelScrubFromSwipe(launcher.appsView)
|
||||
} else {
|
||||
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the drawer in [OPEN_MS]. Asking the state manager to go there would take the
|
||||
* drawer's own 600 ms, far too slow for a list that the finger is already scrubbing.
|
||||
*/
|
||||
private fun openDrawer() {
|
||||
launcher.stateManager.createAnimationToNewWorkspace(LauncherState.ALL_APPS, OPEN_MS).apply {
|
||||
dispatchOnStart()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canStart(ev: MotionEvent): Boolean =
|
||||
prefs.flexeraScrubFromHome.get() &&
|
||||
(ev.edgeFlags and Utilities.EDGE_NAV_BAR) == 0 &&
|
||||
@@ -86,5 +110,8 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
|
||||
private companion object {
|
||||
/** A slide counts as vertical when it has moved this many times further up or down than sideways. */
|
||||
const val VERTICAL_BIAS = 2f
|
||||
|
||||
/** How long the drawer takes to open under a scrub; the same as after swipe up and hold. */
|
||||
const val OPEN_MS = 120L
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,14 @@ import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
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
|
||||
@@ -26,6 +25,8 @@ import androidx.core.view.doOnNextLayout
|
||||
import androidx.customview.widget.ExploreByTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.allapps.ActivityAllAppsContainerView
|
||||
import kotlin.math.abs
|
||||
@@ -37,16 +38,22 @@ import kotlin.math.roundToInt
|
||||
/**
|
||||
* The wave alphabet: a strip of letters down one edge of the drawer (PRD AD-4 to AD-6).
|
||||
*
|
||||
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
|
||||
* Only letters that have apps are shown, under a magnifier that stands for the search field at
|
||||
* the top of the list. Touching the strip and sliding along it jumps the list
|
||||
* to the letter under the finger, with a haptic tick on every change. While the finger is down,
|
||||
* the letters near it bulge away from the edge in a wave. The wave's peak, where the current
|
||||
* letter is, stays a thumb's width beyond the finger however far in from the edge the finger
|
||||
* is, so the letter always sits beside the thumb instead of under it.
|
||||
* the letters near it bulge away from the edge in a wide wave that curves around the thumb.
|
||||
* The wave's peak, where the current letter is, stays a thumb's width beyond the finger however
|
||||
* far in from the edge the finger is, and just beyond the peak a bubble shows that letter
|
||||
* large. The look follows Niagara Launcher's alphabet, the owner's reference.
|
||||
*
|
||||
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15).
|
||||
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15), and low
|
||||
* on that edge, with its letters close together, so that the first letter is as easy to reach
|
||||
* as the last.
|
||||
*
|
||||
* The view covers the whole drawer so it has room to draw the wave, but it only takes touches
|
||||
* that start on the strip; every other touch falls through to the list underneath.
|
||||
* that start on the strip; every other touch falls through to the list underneath. The very
|
||||
* edge is also where Android starts its Back swipe. The strip leaves that to Android: a touch
|
||||
* there becomes a scrub only once it moves up or down or rests, and a sideways swipe goes back.
|
||||
*
|
||||
* [beginScrub], [moveScrub] and [endScrub] are public so that another gesture, such as swipe up
|
||||
* and hold from the home screen (PRD AD-17), can hand its touch over to the alphabet.
|
||||
@@ -60,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)
|
||||
@@ -68,11 +76,15 @@ class WaveAlphabetView(
|
||||
private val edgeInset = res.getDimension(R.dimen.flexera_wave_edge_inset)
|
||||
private val maxSpacing = res.getDimension(R.dimen.flexera_wave_max_spacing)
|
||||
private val verticalMargin = res.getDimension(R.dimen.flexera_wave_vertical_margin)
|
||||
private val bottomOffset = res.getDimension(R.dimen.flexera_wave_bottom_offset)
|
||||
private val fingerGap = res.getDimension(R.dimen.flexera_wave_finger_gap)
|
||||
private val minReach = res.getDimension(R.dimen.flexera_wave_min_reach)
|
||||
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
|
||||
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
|
||||
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
|
||||
private val headerHeight = FlexeraDrawer.headerHeight(context)
|
||||
private val homeTouchWidth = res.getDimension(R.dimen.flexera_wave_home_touch_width)
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
|
||||
/** The section name Launcher3 gives the work profile's info card, an "i" in a circle. */
|
||||
private val workInfoSection = res.getString(R.string.work_profile_edu_section)
|
||||
@@ -94,21 +106,42 @@ class WaveAlphabetView(
|
||||
}
|
||||
private val boldTypeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
|
||||
/**
|
||||
* A soft dark spot behind the selected letter. Out over the list the letter can land on an
|
||||
* app name, and on a light wallpaper white on white; the spot keeps it readable on both.
|
||||
* Drawn as a circle of radius 1 and scaled to size, so nothing is allocated while drawing.
|
||||
*/
|
||||
private val backdropPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = RadialGradient(
|
||||
// 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,
|
||||
0f,
|
||||
1f,
|
||||
intArrayOf(BACKDROP_COLOR, BACKDROP_COLOR, Color.TRANSPARENT),
|
||||
floatArrayOf(0f, BACKDROP_SOLID_SHARE, 1f),
|
||||
Shader.TileMode.CLAMP,
|
||||
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
|
||||
FlexeraDrawer.TEXT_SHADOW_COLOR,
|
||||
)
|
||||
}
|
||||
private val plainMarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
// The bubble that shows the selected letter large, beyond the wave's peak: a light disc
|
||||
// with a dark letter, the one thing on the dark drawer that is not white on dark.
|
||||
private val bubbleRadius = res.getDimension(R.dimen.flexera_wave_bubble_radius)
|
||||
private val bubbleGap = res.getDimension(R.dimen.flexera_wave_bubble_gap)
|
||||
private val bubbleTextSize = res.getDimension(R.dimen.flexera_wave_bubble_text_size)
|
||||
private val bubbleEdgeMargin = res.getDimension(R.dimen.flexera_wave_bubble_edge_margin)
|
||||
private val bubblePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = BUBBLE_COLOR
|
||||
setShadowLayer(
|
||||
res.getDimension(R.dimen.flexera_wave_bubble_shadow_radius),
|
||||
0f,
|
||||
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
|
||||
BUBBLE_SHADOW_COLOR,
|
||||
)
|
||||
}
|
||||
private val bubbleTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = BUBBLE_TEXT_COLOR
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = boldTypeface
|
||||
}
|
||||
|
||||
private var letters: List<Letter> = emptyList()
|
||||
private var letterYs = FloatArray(0)
|
||||
@@ -125,15 +158,25 @@ class WaveAlphabetView(
|
||||
private var fingerY = 0f
|
||||
private var selected = -1
|
||||
|
||||
/** Where the list stood when the scrub began (first visible row and its offset), for [cancelScrub]. */
|
||||
private var listPositionBefore = RecyclerView.NO_POSITION
|
||||
private var listOffsetBefore = 0
|
||||
// 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)
|
||||
|
||||
@@ -161,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) {
|
||||
@@ -184,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
|
||||
@@ -199,8 +251,15 @@ class WaveAlphabetView(
|
||||
} else {
|
||||
val spacing = min(maxSpacing, (availableBottom - availableTop) / count)
|
||||
val blockHeight = spacing * count
|
||||
// Few letters: keep the block in the middle of the edge, where a thumb rests.
|
||||
stripTop = availableTop + (availableBottom - availableTop - blockHeight) / 2f
|
||||
// The stretch of edge a whole alphabet would take sits low, where a thumb reaches
|
||||
// without stretching: its end a little above the navigation bar, its start
|
||||
// wherever that puts it. The letters there are sit in the middle of that stretch,
|
||||
// so that a short alphabet is in the middle of the thumb's reach and not at its
|
||||
// lower end. Only letters that need the whole edge reach up to where the list starts.
|
||||
val fullCount = FULL_ALPHABET + if (letters.first().isSearch) 1 else 0
|
||||
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * fullCount))
|
||||
val zoneBottom = availableBottom - min(bottomOffset, availableBottom - availableTop - zoneHeight)
|
||||
stripTop = zoneBottom - zoneHeight + (zoneHeight - blockHeight) / 2f
|
||||
stripBottom = stripTop + blockHeight
|
||||
for (i in 0 until count) {
|
||||
letterYs[i] = stripTop + spacing * (i + 0.5f)
|
||||
@@ -210,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) {
|
||||
@@ -235,6 +287,7 @@ class WaveAlphabetView(
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
|
||||
stopWaitingForBack()
|
||||
pressAnimator?.cancel()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
@@ -255,15 +308,17 @@ class WaveAlphabetView(
|
||||
val maxReach = max(minReach + 1f, width - edgeInset - farMargin)
|
||||
val fullReach = (fingerDistance + fingerGap).coerceIn(minReach, maxReach)
|
||||
val reach = fullReach * press
|
||||
// A taller wave is also a wider one. It keeps its shape that way, and the letters next
|
||||
// to the selected one bend around the thumb instead of vanishing under it.
|
||||
// The wave is wide: several letters on each side of the finger ride it, so they curve
|
||||
// around the whole thumb and stay readable above and below it. A wave that reaches
|
||||
// far out is wider still, which keeps its shape.
|
||||
val spread = max(waveWidth, fullReach * WAVE_WIDTH_SHARE_OF_REACH)
|
||||
|
||||
// Darken the list under the wave where its letters cross icons and names. From the
|
||||
// right edge that only happens once the wave reaches well into the screen, as it does
|
||||
// after swipe up and hold; from the left edge the icons sit right beside the strip.
|
||||
// Darken the list a little while scrubbing, so the eye goes to the letters, and more
|
||||
// where the wave lies over icons and names: from the right edge that is once it reaches
|
||||
// well into the screen, as after swipe up and hold; from the left edge it is at once.
|
||||
if (press > 0f) {
|
||||
val overList = if (onLeft) 1f else ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
|
||||
val reachShare = ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
|
||||
val overList = if (onLeft) 1f else SCRUB_DIM_AT_EDGE + (1f - SCRUB_DIM_AT_EDGE) * reachShare
|
||||
val dim = (SCRUB_DIM * press * overList * 255).toInt()
|
||||
if (dim > 0) canvas.drawColor(Color.argb(dim, 0, 0, 0))
|
||||
}
|
||||
@@ -273,8 +328,9 @@ class WaveAlphabetView(
|
||||
val nearness = if (press > 0f) bell((y - fingerY) / spread) else 0f
|
||||
val isSelected = i == selected && press > 0f
|
||||
|
||||
val scale = 1f + (if (isSelected) SELECTED_GROWTH else NEAR_GROWTH) * nearness * press
|
||||
paint.textSize = restingLetterSize * scale
|
||||
// Letters keep their size in the wave, so close neighbours never collide; the
|
||||
// selected one is bold and a touch larger. Its large copy is in the bubble.
|
||||
paint.textSize = restingLetterSize * (1f + (if (isSelected) SELECTED_GROWTH * press else 0f))
|
||||
paint.typeface = if (isSelected) boldTypeface else Typeface.DEFAULT
|
||||
paint.color = if (isSelected) {
|
||||
activeColor
|
||||
@@ -283,19 +339,50 @@ class WaveAlphabetView(
|
||||
}
|
||||
|
||||
val x = baseX + inward * reach * nearness
|
||||
if (isSelected) {
|
||||
val radius = paint.textSize * BACKDROP_RADIUS_IN_LETTER_SIZES
|
||||
backdropPaint.alpha = (press * 255).toInt()
|
||||
canvas.save()
|
||||
canvas.translate(x, y)
|
||||
canvas.scale(radius, radius)
|
||||
canvas.drawCircle(0f, 0f, 1f, backdropPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
// Centre the glyph on the letter's line.
|
||||
val baseline = y - (paint.descent() + paint.ascent()) / 2f
|
||||
canvas.drawText(letters[i].label, x, baseline, paint)
|
||||
if (letters[i].isSearch) {
|
||||
drawSearchMark(canvas, x, y, paint.textSize * SEARCH_MARK_IN_LETTER_SIZES, paint.color, true)
|
||||
} else {
|
||||
val baseline = y - (paint.descent() + paint.ascent()) / 2f
|
||||
canvas.drawText(letters[i].label, x, baseline, paint)
|
||||
}
|
||||
}
|
||||
|
||||
// The selected letter, large, in a bubble just beyond the wave's peak and level with
|
||||
// the finger: the furthest thing from the thumb, so the thumb cannot hide it.
|
||||
if (press > 0f && selected in letters.indices) {
|
||||
val edgeRoom = bubbleRadius + bubbleEdgeMargin
|
||||
val cx = (baseX + inward * (reach + bubbleGap * press)).coerceIn(edgeRoom, max(edgeRoom, width - edgeRoom))
|
||||
val alpha = (press * 255).toInt()
|
||||
bubblePaint.alpha = alpha
|
||||
canvas.drawCircle(cx, fingerY, bubbleRadius * press, bubblePaint)
|
||||
bubbleTextPaint.alpha = alpha
|
||||
bubbleTextPaint.textSize = bubbleTextSize * press
|
||||
if (letters[selected].isSearch) {
|
||||
drawSearchMark(canvas, cx, fingerY, bubbleTextPaint.textSize, bubbleTextPaint.color, false)
|
||||
} else {
|
||||
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
|
||||
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a magnifier, the mark for the search field, [size] across and centred on
|
||||
* ([cx], [cy]): a ring with a short handle. Drawn by hand so that its line weight
|
||||
* matches letters of any size.
|
||||
*/
|
||||
private fun drawSearchMark(canvas: Canvas, cx: Float, cy: Float, size: Float, color: Int, shadow: Boolean) {
|
||||
val stroke = if (shadow) markPaint else plainMarkPaint
|
||||
stroke.color = color
|
||||
stroke.strokeWidth = size * 0.12f
|
||||
val radius = size * 0.29f
|
||||
val ringX = cx - size * 0.09f
|
||||
val ringY = cy - size * 0.09f
|
||||
canvas.drawCircle(ringX, ringY, radius, stroke)
|
||||
val rim = radius * 0.7071f
|
||||
val handle = size * 0.27f
|
||||
canvas.drawLine(ringX + rim, ringY + rim, ringX + rim + handle, ringY + rim + handle, stroke)
|
||||
}
|
||||
|
||||
private fun bell(distance: Float): Float = exp(-distance * distance)
|
||||
@@ -310,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) {
|
||||
@@ -324,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
|
||||
}
|
||||
@@ -341,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()
|
||||
@@ -348,33 +490,29 @@ class WaveAlphabetView(
|
||||
if (letters.isEmpty()) return
|
||||
scrubbing = true
|
||||
selected = -1
|
||||
rememberListPosition()
|
||||
animatePress(1f, PRESS_IN_MS)
|
||||
moveScrub(x, y)
|
||||
}
|
||||
|
||||
private fun rememberListPosition() {
|
||||
listPositionBefore = RecyclerView.NO_POSITION
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
listPositionBefore = position
|
||||
listOffsetBefore = (layoutManager.findViewByPosition(position)?.top ?: rv.paddingTop) - rv.paddingTop
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the scrub and puts the list back where it stood before it, as if the scrub had not
|
||||
* happened. For a scrub that turns out to have been started by mistake.
|
||||
* Ends a scrub that began with the drawer closed and undoes it: the list goes to where a
|
||||
* drawer opened by a plain swipe stands, which is the top unless the owner has the drawer
|
||||
* remember its place. For a scrub that turns out to have been started by mistake.
|
||||
*
|
||||
* The list is not asked where it stood before the scrub. A closed drawer has been told to
|
||||
* go back to the top but lays its rows out again only when it opens, so until then the
|
||||
* rows still report the place the drawer was left at.
|
||||
*/
|
||||
fun cancelScrub() {
|
||||
if (!scrubbing) return
|
||||
endScrub()
|
||||
if (listPositionBefore == RecyclerView.NO_POSITION) return
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
rv.stopScroll()
|
||||
(rv.layoutManager as? LinearLayoutManager)
|
||||
?.scrollToPositionWithOffset(listPositionBefore, listOffsetBefore)
|
||||
if (PreferenceManager2.getInstance(context).rememberPosition.firstCached()) {
|
||||
rv.restoreScrollPosition()
|
||||
} else {
|
||||
rv.scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the finger of a scrub started with [beginScrub]. */
|
||||
@@ -388,7 +526,7 @@ class WaveAlphabetView(
|
||||
if (index != selected) {
|
||||
selected = index
|
||||
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
scrollListTo(letters[index].adapterPosition)
|
||||
scrollListTo(index)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
@@ -402,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) {
|
||||
@@ -466,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()
|
||||
@@ -491,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,
|
||||
@@ -520,26 +681,42 @@ class WaveAlphabetView(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** How much larger than its resting size the selected letter grows. */
|
||||
const val SELECTED_GROWTH = 1.5f
|
||||
/** Where a chosen letter's group is centred, and the highest its header may go, as shares of the list's height. */
|
||||
const val GROUP_MIDDLE = 0.5f
|
||||
const val GROUP_HIGHEST_TOP = 0.22f
|
||||
|
||||
/** How much its neighbours grow, tapering with distance from the finger. */
|
||||
const val NEAR_GROWTH = 0.5f
|
||||
/** The magnifier's size next to a letter of the same text size. */
|
||||
const val SEARCH_MARK_IN_LETTER_SIZES = 1.1f
|
||||
|
||||
/** The spot behind the selected letter: black at 60%, solid to this share of its radius. */
|
||||
const val BACKDROP_COLOR = 0x99000000.toInt()
|
||||
const val BACKDROP_SOLID_SHARE = 0.55f
|
||||
const val BACKDROP_RADIUS_IN_LETTER_SIZES = 1.15f
|
||||
/** How many letters a whole alphabet has; the stretch of edge kept for the strip is sized for it. */
|
||||
const val FULL_ALPHABET = 26
|
||||
|
||||
/** How much larger than its resting size the selected letter is in the wave itself. */
|
||||
const val SELECTED_GROWTH = 0.25f
|
||||
|
||||
/** The bubble: a near-white disc, its letter near-black, a soft shadow under it. */
|
||||
const val BUBBLE_COLOR = 0xFFF1F1F4.toInt()
|
||||
const val BUBBLE_TEXT_COLOR = 0xFF17181C.toInt()
|
||||
const val BUBBLE_SHADOW_COLOR = 0x66000000
|
||||
|
||||
/** The wave's spread up and down the edge, as a share of how far it reaches out. */
|
||||
const val WAVE_WIDTH_SHARE_OF_REACH = 0.33f
|
||||
const val WAVE_WIDTH_SHARE_OF_REACH = 0.36f
|
||||
|
||||
/** How dark the list gets under a wave that lies fully over it (0 = unchanged, 1 = black). */
|
||||
const val SCRUB_DIM = 0.55f
|
||||
|
||||
/** The share of [SCRUB_DIM] applied when the wave stays at the right edge, clear of the list. */
|
||||
const val SCRUB_DIM_AT_EDGE = 0.35f
|
||||
|
||||
/** The tallest a resting letter may be, as a share of the vertical room it has. */
|
||||
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
|
||||
|
||||
/**
|
||||
* How long a touch on the Back strip may rest before it counts as a scrub. Long
|
||||
* enough for a Back swipe to have started moving, short enough not to be felt.
|
||||
*/
|
||||
const val REST_BEFORE_SCRUB_MS = 130L
|
||||
|
||||
const val PRESS_IN_MS = 110L
|
||||
const val PRESS_OUT_MS = 220L
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -30,9 +32,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -40,6 +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
|
||||
@@ -54,6 +62,9 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
|
||||
import app.lawnchair.ui.preferences.navigation.AboutLicenses
|
||||
import com.android.launcher3.BuildConfig
|
||||
import com.android.launcher3.R
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import com.google.zxing.qrcode.encoder.Encoder
|
||||
import kotlin.math.floor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -62,6 +73,12 @@ private const val LAWNCHAIR_SOURCE_URL = "https://github.com/LawnchairLauncher/l
|
||||
/** Release notes longer than this are cut off; the full text is on the release page. */
|
||||
private const val MAX_NOTES_LENGTH = 600
|
||||
|
||||
/** Large enough to scan from another phone held at arm's length. */
|
||||
private val QR_CODE_SIZE = 220.dp
|
||||
|
||||
/** The blank border around a QR code, in modules; the standard asks for four. */
|
||||
private const val QR_QUIET_ZONE_MODULES = 4
|
||||
|
||||
/**
|
||||
* Flexera Launcher's About screen.
|
||||
*
|
||||
@@ -178,6 +195,9 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_update_heading)) }
|
||||
groupRows(updateRows)
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
|
||||
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
|
||||
groupRows(
|
||||
listOf<@Composable () -> Unit>(
|
||||
@@ -275,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 {
|
||||
@@ -295,6 +315,90 @@ private fun UpdateStatusRow(state: UpdateState) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the owner holds up for someone who wants the app: a QR code that opens the release page,
|
||||
* with the address under it for anyone who would rather type it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareCard(url: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
QrCode(
|
||||
text = url,
|
||||
description = stringResource(id = R.string.flexera_about_share_qr_description),
|
||||
modifier = Modifier.size(QR_CODE_SIZE),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.flexera_about_share_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = url.removePrefix("https://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A QR code for [text], made on the phone. It is black on white in every theme, with the blank
|
||||
* border the standard asks for, because that is what scanners read most reliably.
|
||||
*/
|
||||
@Composable
|
||||
private fun QrCode(text: String, description: String, modifier: Modifier = Modifier) {
|
||||
val modules = remember(text) { Encoder.encode(text, ErrorCorrectionLevel.M).matrix }
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
drawRect(Color.White)
|
||||
// Whole pixels per module, so neighbouring modules join without a seam.
|
||||
val count = modules.width + 2 * QR_QUIET_ZONE_MODULES
|
||||
val module = floor(size.minDimension / count)
|
||||
val left = floor((size.width - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
|
||||
val top = floor((size.height - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
|
||||
for (y in 0 until modules.height) {
|
||||
for (x in 0 until modules.width) {
|
||||
if (modules.get(x, y).toInt() == 1) {
|
||||
drawRect(
|
||||
color = Color.Black,
|
||||
topLeft = Offset(left + x * module, top + y * module),
|
||||
size = Size(module, module),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says why a check for updates failed and what to do about it, then the error's own words, which
|
||||
* are what someone looking for the cause will ask for.
|
||||
*/
|
||||
@Composable
|
||||
private fun checkFailureText(state: UpdateState.Failed): String {
|
||||
val host = FlexeraUpdater.SERVER_HOST
|
||||
val reason = when (state.problem) {
|
||||
CheckProblem.NO_PERMISSION -> stringResource(id = R.string.flexera_update_failed_no_permission)
|
||||
CheckProblem.SERVER_NOT_FOUND -> stringResource(id = R.string.flexera_update_failed_not_found, host)
|
||||
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
|
||||
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
|
||||
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
|
||||
CheckProblem.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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.android.launcher3.LauncherAppState
|
||||
import com.android.launcher3.LauncherSettings.Favorites
|
||||
import com.android.launcher3.R
|
||||
import kotlin.math.min
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Lets the owner choose the pictures of a picture widget. It has no screen of its own: it opens
|
||||
* Android's photo picker, copies what was chosen into the widget's storage and closes.
|
||||
*
|
||||
* Android starts it when the widget is put on the home screen and from the widget's edit
|
||||
* button; the widget starts it itself when it has no pictures. The photo picker hands over
|
||||
* only the pictures the owner picks, so the launcher needs no access to the gallery.
|
||||
*/
|
||||
class PictureWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
private val picker = registerForActivityResult(
|
||||
ActivityResultContracts.PickMultipleVisualMedia(mostPictures()),
|
||||
) { chosen -> onChosen(chosen) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
appWidgetId = intent?.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
// Leaving without pictures means "no": a widget that was just being added is dropped.
|
||||
setResult(RESULT_CANCELED, result())
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// After Android has restarted this screen, the picker is already open and answers here.
|
||||
if (savedInstanceState != null) return
|
||||
val widget = appWidgetId
|
||||
lifecycleScope.launch {
|
||||
// A widget that came with an import already has its pictures waiting.
|
||||
val adopted = withContext(Dispatchers.IO) { adoptImported(widget) }
|
||||
if (adopted) {
|
||||
setResult(RESULT_OK, result())
|
||||
finish()
|
||||
} else {
|
||||
picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the widget the pictures an import kept for its place on the home screen, if this is
|
||||
* such a widget and it has none yet. The launcher's database knows where the widget sits,
|
||||
* and an import keeps its pictures under that place.
|
||||
*/
|
||||
private fun adoptImported(widget: Int): Boolean {
|
||||
val context = applicationContext
|
||||
if (!PictureWidgetStore.hasImports(context)) return false
|
||||
if (PictureWidgetStore.pictures(context, widget).isNotEmpty()) return false
|
||||
return try {
|
||||
LauncherAppState.getInstance(context).model.modelDbController.query(
|
||||
arrayOf(Favorites.SCREEN, Favorites.CELLX, Favorites.CELLY),
|
||||
"${Favorites.APPWIDGET_ID} = ? AND ${Favorites.ITEM_TYPE} = ${Favorites.ITEM_TYPE_APPWIDGET} " +
|
||||
"AND ${Favorites.CONTAINER} = ${Favorites.CONTAINER_DESKTOP}",
|
||||
arrayOf(widget.toString()),
|
||||
null,
|
||||
).use { row ->
|
||||
row.moveToFirst() &&
|
||||
PictureWidgetStore.adoptImport(context, widget, row.getInt(0), row.getInt(1), row.getInt(2))
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Could not look for imported pictures", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun onChosen(chosen: List<Uri>) {
|
||||
if (chosen.isEmpty()) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Copying a few large photos takes a moment.
|
||||
setContentView(
|
||||
FrameLayout(this).apply {
|
||||
addView(
|
||||
ProgressBar(context),
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.CENTER,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val widget = appWidgetId
|
||||
lifecycleScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
PictureWidgetStore.replace(applicationContext, widget, chosen)
|
||||
}
|
||||
if (saved > 0) {
|
||||
setResult(RESULT_OK, result())
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this@PictureWidgetConfigActivity,
|
||||
R.string.flexera_picture_widget_unreadable,
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun result() = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PictureWidgetConfig"
|
||||
|
||||
/** How many pictures the photo picker lets the owner tick. */
|
||||
fun mostPictures(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
min(PictureWidgetStore.MAX_PICTURES, MediaStore.getPickImagesMaxLimit())
|
||||
} else {
|
||||
PictureWidgetStore.MAX_PICTURES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import com.android.launcher3.BuildConfig
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
|
||||
/**
|
||||
* The launcher's own picture widget: one picture, or several that take turns.
|
||||
*
|
||||
* Android knows it as an ordinary app widget, so it is added, moved, resized and removed like
|
||||
* any other. What it shows is not sent through Android, though: the launcher draws it itself
|
||||
* with a [PictureWidgetView], which is what lets it line up with the icons around it. Its
|
||||
* pictures live in [PictureWidgetStore].
|
||||
*/
|
||||
class PictureWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
appWidgetIds.forEach { PictureWidgetStore.delete(context, it) }
|
||||
}
|
||||
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
for (i in oldWidgetIds.indices) {
|
||||
val newId = newWidgetIds.getOrNull(i) ?: continue
|
||||
PictureWidgetStore.move(context, oldWidgetIds[i], newId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, PictureWidgetProvider::class.java.name)
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget
|
||||
* the same ([standard]); the picture widget takes none, because it places its picture
|
||||
* against the icons of its cells by itself.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (info.providerName == componentName) noInset else standard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Where the picture widget keeps its pictures: copies in the app's own storage, one folder per
|
||||
* widget on the home screen.
|
||||
*
|
||||
* Copies, because Android's photo picker lends a picture only for a while, and because a copy
|
||||
* needs no permission to read the gallery later. Each copy is scaled down to what a widget can
|
||||
* show, so a slideshow of large photos stays small.
|
||||
*/
|
||||
object PictureWidgetStore {
|
||||
|
||||
/** Told, on the main thread, that the pictures of a widget were replaced or removed. */
|
||||
fun interface Listener {
|
||||
fun onPicturesChanged(appWidgetId: Int)
|
||||
}
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<Listener>()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/** The pictures of one widget, in the order they were chosen. Empty when it has none. */
|
||||
fun pictures(context: Context, appWidgetId: Int): List<File> =
|
||||
folder(context, appWidgetId).listFiles { file -> file.isFile }?.sortedBy { it.name }.orEmpty()
|
||||
|
||||
/**
|
||||
* Gives a widget copies of [sources] in place of the pictures it had, and returns how many
|
||||
* were saved. A source that cannot be read is left out; when none can be read the widget
|
||||
* keeps what it had and the result is 0.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun replace(context: Context, appWidgetId: Int, sources: List<Uri>): Int {
|
||||
val incoming = File(root(context), "$appWidgetId$INCOMING_SUFFIX")
|
||||
var saved = saveCopies(context, incoming, sources)
|
||||
if (saved == 0) return 0
|
||||
val folder = folder(context, appWidgetId)
|
||||
folder.deleteRecursively()
|
||||
if (!incoming.renameTo(folder)) {
|
||||
incoming.deleteRecursively()
|
||||
saved = 0
|
||||
}
|
||||
notifyChanged(appWidgetId)
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps copies of [sources] for a picture widget that an import has put at a place on a
|
||||
* home screen. Such a widget has no id yet: Android gives it one when the owner taps it to
|
||||
* set it up, and [adoptImport] then hands it these pictures. Returns how many were saved.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun stageImport(context: Context, screen: Int, cellX: Int, cellY: Int, sources: List<Uri>): Int =
|
||||
saveCopies(context, importFolder(context, screen, cellX, cellY), sources)
|
||||
|
||||
/** True while pictures of an import wait for their widgets. */
|
||||
fun hasImports(context: Context): Boolean =
|
||||
root(context).listFiles { file -> file.isDirectory && file.name.startsWith(IMPORT_PREFIX) }
|
||||
?.isNotEmpty() == true
|
||||
|
||||
/** Forgets the pictures of an earlier import that were never claimed. */
|
||||
fun clearImports(context: Context) {
|
||||
root(context).listFiles { file -> file.isDirectory && file.name.startsWith(IMPORT_PREFIX) }
|
||||
?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives a widget the pictures an import kept for the place it sits at. True if there were
|
||||
* any; the widget then needs no pictures chosen for it.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun adoptImport(context: Context, appWidgetId: Int, screen: Int, cellX: Int, cellY: Int): Boolean {
|
||||
val staged = importFolder(context, screen, cellX, cellY)
|
||||
if (staged.listFiles { file -> file.isFile }.isNullOrEmpty()) return false
|
||||
val folder = folder(context, appWidgetId)
|
||||
folder.deleteRecursively()
|
||||
if (!staged.renameTo(folder)) return false
|
||||
notifyChanged(appWidgetId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Fills [into] with copies of [sources], replacing what it held. Returns how many were saved. */
|
||||
private fun saveCopies(context: Context, into: File, sources: List<Uri>): Int {
|
||||
into.deleteRecursively()
|
||||
if (!into.mkdirs()) return 0
|
||||
|
||||
var saved = 0
|
||||
for (source in sources.take(MAX_PICTURES)) {
|
||||
val bitmap = try {
|
||||
decode(context, source)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "A chosen picture could not be read", e)
|
||||
null
|
||||
} ?: continue
|
||||
// A picture with see-through parts keeps them; everything else is a photo.
|
||||
val keepsAlpha = isSeeThrough(bitmap)
|
||||
val name = String.format(Locale.ROOT, "%03d.%s", saved, if (keepsAlpha) "png" else "jpg")
|
||||
val file = File(into, name)
|
||||
val written = try {
|
||||
file.outputStream().use { out ->
|
||||
bitmap.compress(
|
||||
if (keepsAlpha) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG,
|
||||
JPEG_QUALITY,
|
||||
out,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "A chosen picture could not be saved", e)
|
||||
false
|
||||
}
|
||||
bitmap.recycle()
|
||||
if (written) saved++ else file.delete()
|
||||
}
|
||||
|
||||
if (saved == 0) into.deleteRecursively()
|
||||
return saved
|
||||
}
|
||||
|
||||
/** Removes the pictures of a widget that left the home screen. */
|
||||
fun delete(context: Context, appWidgetId: Int) {
|
||||
folder(context, appWidgetId).deleteRecursively()
|
||||
File(root(context), "$appWidgetId$INCOMING_SUFFIX").deleteRecursively()
|
||||
notifyChanged(appWidgetId)
|
||||
}
|
||||
|
||||
/** Hands the pictures of a widget to the id Android gave it after a restore. */
|
||||
fun move(context: Context, fromAppWidgetId: Int, toAppWidgetId: Int) {
|
||||
val from = folder(context, fromAppWidgetId)
|
||||
if (!from.isDirectory || fromAppWidgetId == toAppWidgetId) return
|
||||
val to = folder(context, toAppWidgetId)
|
||||
to.deleteRecursively()
|
||||
from.renameTo(to)
|
||||
notifyChanged(toAppWidgetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a stored picture for a frame of [width] by [height] pixels: at the smallest size
|
||||
* that still covers the frame, so that no more memory is used than the screen can show.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun load(file: File, width: Int, height: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= width && bounds.outHeight / (sample * 2) >= height) {
|
||||
sample *= 2
|
||||
}
|
||||
return BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
}
|
||||
|
||||
/** Reads a chosen picture, upright and no larger than [MAX_EDGE] on its long side. */
|
||||
private fun decode(context: Context, source: Uri): Bitmap? {
|
||||
val resolver = context.contentResolver
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
// ImageDecoder turns the picture the way the camera held it and knows the newer
|
||||
// formats (HEIC, AVIF).
|
||||
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, source)) { decoder, info, _ ->
|
||||
// The copy is compressed right after, which needs the pixels in ordinary memory.
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
val longEdge = max(info.size.width, info.size.height)
|
||||
if (longEdge > MAX_EDGE) {
|
||||
val scale = MAX_EDGE.toFloat() / longEdge
|
||||
decoder.setTargetSize(
|
||||
max(1, (info.size.width * scale).roundToInt()),
|
||||
max(1, (info.size.height * scale).roundToInt()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android 8: no ImageDecoder. Photos taken sideways stay sideways there.
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
var sample = 1
|
||||
while (max(bounds.outWidth, bounds.outHeight) / sample > MAX_EDGE) sample *= 2
|
||||
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
return resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* True if some of the picture lets the background through. Many pictures can hold such
|
||||
* parts without having any (every screenshot, for one), so the pixels are looked at.
|
||||
*/
|
||||
private fun isSeeThrough(bitmap: Bitmap): Boolean {
|
||||
if (!bitmap.hasAlpha()) return false
|
||||
val row = IntArray(bitmap.width)
|
||||
for (y in 0 until bitmap.height) {
|
||||
bitmap.getPixels(row, 0, bitmap.width, 0, y, bitmap.width, 1)
|
||||
if (row.any { it ushr 24 != 0xFF }) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun notifyChanged(appWidgetId: Int) {
|
||||
mainHandler.post { listeners.forEach { it.onPicturesChanged(appWidgetId) } }
|
||||
}
|
||||
|
||||
private fun root(context: Context) = File(context.filesDir, FOLDER)
|
||||
|
||||
private fun folder(context: Context, appWidgetId: Int) = File(root(context), appWidgetId.toString())
|
||||
|
||||
private fun importFolder(context: Context, screen: Int, cellX: Int, cellY: Int) =
|
||||
File(root(context), "$IMPORT_PREFIX$screen-$cellX-$cellY")
|
||||
|
||||
private const val TAG = "PictureWidgetStore"
|
||||
private const val FOLDER = "flexera_picture_widget"
|
||||
private const val INCOMING_SUFFIX = ".incoming"
|
||||
private const val IMPORT_PREFIX = "import-"
|
||||
|
||||
/** The most pictures one widget holds. */
|
||||
const val MAX_PICTURES = 50
|
||||
|
||||
/**
|
||||
* The long side of a stored picture, in pixels. A widget is never wider than the screen,
|
||||
* and a picture is cropped to fill it, so this leaves room to crop on any phone.
|
||||
*/
|
||||
private const val MAX_EDGE = 2048
|
||||
|
||||
private const val JPEG_QUALITY = 90
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import app.lawnchair.icons.shape.CornerShapeCompat
|
||||
import app.lawnchair.icons.shape.IconShape
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.ShortcutAndWidgetContainer
|
||||
import com.android.launcher3.celllayout.CellLayoutLayoutParams
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.util.Executors
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* What the picture widget ([PictureWidgetProvider]) looks like on the home screen: one picture
|
||||
* cropped to fill a frame, or several that take turns every [INTERVAL_MS].
|
||||
*
|
||||
* The frame is made to look like a large icon. Its edges are the outer edges of the icons that
|
||||
* would sit in the cells the widget covers, and its corners are the corners of the icon shape
|
||||
* in use, so the picture lines up with the icons beside, above and below it. The view itself is
|
||||
* larger than the frame: it fills the widget's cells and draws nothing outside the frame.
|
||||
*
|
||||
* In the list of widgets and while it is dragged from there, the same view shows a sample.
|
||||
*/
|
||||
class PictureWidgetView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs),
|
||||
PictureWidgetStore.Listener {
|
||||
|
||||
/** A picture ready to draw. */
|
||||
private class Slide(val bitmap: Bitmap) {
|
||||
val shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
}
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
/** True while this is only a sample of the widget, with no pictures of its own. */
|
||||
private var sample = true
|
||||
|
||||
private var pictures: List<File> = emptyList()
|
||||
private var index = 0
|
||||
private var shown: Slide? = null
|
||||
|
||||
// The picture that is fading in over the shown one, and its place among the pictures.
|
||||
private var incoming: Slide? = null
|
||||
private var incomingIndex = 0
|
||||
private var fade = 0f
|
||||
private var fadeAnimator: ValueAnimator? = null
|
||||
|
||||
// Pictures are read off the main thread. A result is used only if no newer read was started.
|
||||
private var loadGeneration = 0
|
||||
private var loading = false
|
||||
|
||||
/** The frame size the shown picture was read for; 0 before the first read. */
|
||||
private var loadedWidth = 0
|
||||
private var loadedHeight = 0
|
||||
|
||||
private var onScreen = false
|
||||
|
||||
private val frame = RectF()
|
||||
private val shape = Path()
|
||||
private val farHill = Path()
|
||||
private val nearHill = Path()
|
||||
private val sun = Path()
|
||||
private val scratch = Path()
|
||||
private val cropMatrix = Matrix()
|
||||
private var hint: StaticLayout? = null
|
||||
private var sampleShader: Shader? = null
|
||||
|
||||
private val picturePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
private val platePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = PLATE_COLOR }
|
||||
private val sceneryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = SCENERY_COLOR }
|
||||
private val hintPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = HINT_COLOR
|
||||
textSize = resources.getDimension(R.dimen.flexera_picture_widget_hint_text_size)
|
||||
}
|
||||
|
||||
private val advance = Runnable { showNext() }
|
||||
private val reloadForSize = Runnable { reload() }
|
||||
|
||||
// The icon shape, as corners that can be put on a rectangle of any size. Null for the few
|
||||
// shapes that are one fixed outline; those get plain round corners.
|
||||
private val cornerShape: IconShape.CornerBased? by lazy(LazyThreadSafetyMode.NONE) {
|
||||
when (val iconShape = currentIconShape()) {
|
||||
is IconShape.SystemBased -> iconShape.findNearestShape()
|
||||
is IconShape.CornerBased -> iconShape
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
// Launcher3 rounds the corners of every widget that does not round its own. This id
|
||||
// together with clipToOutline is how a widget says that it does (see
|
||||
// RoundedCornerEnforcement); without it the picture's corners would be cut twice.
|
||||
id = android.R.id.background
|
||||
clipToOutline = true
|
||||
}
|
||||
|
||||
/** Tells the view which widget it shows. Ids that Android never hands out mean a sample. */
|
||||
fun bind(appWidgetId: Int) {
|
||||
this.appWidgetId = appWidgetId
|
||||
sample = appWidgetId <= 0
|
||||
if (sample) {
|
||||
// In the list of widgets a tap belongs to the list.
|
||||
setOnClickListener(null)
|
||||
isClickable = false
|
||||
} else {
|
||||
setOnClickListener { onTap() }
|
||||
}
|
||||
index = 0
|
||||
// What the frame holds depends on whether this is a sample.
|
||||
frame.setEmpty()
|
||||
if (isLaidOut) updateFrame()
|
||||
if (isAttachedToWindow) reload()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
PictureWidgetStore.addListener(this)
|
||||
reload()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
PictureWidgetStore.removeListener(this)
|
||||
fadeAnimator?.cancel()
|
||||
removeCallbacks(advance)
|
||||
removeCallbacks(reloadForSize)
|
||||
// Whatever is still being read is no longer wanted.
|
||||
loadGeneration++
|
||||
loading = false
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onVisibilityAggregated(isVisible: Boolean) {
|
||||
super.onVisibilityAggregated(isVisible)
|
||||
// The pictures only take turns while the home screen can be seen.
|
||||
onScreen = isVisible
|
||||
if (isVisible) scheduleNext() else removeCallbacks(advance)
|
||||
}
|
||||
|
||||
override fun onPicturesChanged(appWidgetId: Int) {
|
||||
if (appWidgetId != this.appWidgetId || sample) return
|
||||
index = 0
|
||||
reload()
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
updateFrame()
|
||||
if (!isAttachedToWindow || frame.isEmpty || pictures.isEmpty()) return
|
||||
val width = frame.width().roundToInt()
|
||||
val height = frame.height().roundToInt()
|
||||
if (width == loadedWidth && height == loadedHeight) return
|
||||
removeCallbacks(reloadForSize)
|
||||
if (loadedWidth == 0) {
|
||||
// The first time the size is known.
|
||||
post(reloadForSize)
|
||||
} else {
|
||||
// The widget is being resized: read the picture anew once the size has settled.
|
||||
postDelayed(reloadForSize, RESIZE_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the widget's pictures anew and shows the one whose turn it is. */
|
||||
private fun reload() {
|
||||
fadeAnimator?.cancel()
|
||||
removeCallbacks(advance)
|
||||
removeCallbacks(reloadForSize)
|
||||
incoming = null
|
||||
pictures = if (sample) emptyList() else PictureWidgetStore.pictures(context, appWidgetId)
|
||||
updateDescription()
|
||||
if (pictures.isEmpty()) {
|
||||
loadGeneration++
|
||||
loading = false
|
||||
shown = null
|
||||
loadedWidth = 0
|
||||
loadedHeight = 0
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
if (frame.isEmpty) {
|
||||
// Not laid out yet; onLayout comes back here.
|
||||
loadedWidth = 0
|
||||
loadedHeight = 0
|
||||
return
|
||||
}
|
||||
index = index.coerceIn(0, pictures.lastIndex)
|
||||
loadedWidth = frame.width().roundToInt()
|
||||
loadedHeight = frame.height().roundToInt()
|
||||
load(index) { slide ->
|
||||
shown = slide
|
||||
invalidate()
|
||||
scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(position: Int, then: (Slide?) -> Unit) {
|
||||
val file = pictures.getOrNull(position)
|
||||
if (file == null) {
|
||||
then(null)
|
||||
return
|
||||
}
|
||||
val width = max(1, frame.width().roundToInt())
|
||||
val height = max(1, frame.height().roundToInt())
|
||||
val generation = ++loadGeneration
|
||||
loading = true
|
||||
Executors.THREAD_POOL_EXECUTOR.execute {
|
||||
val bitmap = try {
|
||||
PictureWidgetStore.load(file, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "A picture could not be read", e)
|
||||
null
|
||||
} catch (e: OutOfMemoryError) {
|
||||
Log.w(TAG, "A picture was too large to read", e)
|
||||
null
|
||||
}
|
||||
Executors.MAIN_EXECUTOR.execute {
|
||||
if (generation == loadGeneration) {
|
||||
loading = false
|
||||
then(bitmap?.let(::Slide))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleNext() {
|
||||
removeCallbacks(advance)
|
||||
if (onScreen && !sample && pictures.size > 1) postDelayed(advance, INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun showNext() {
|
||||
if (pictures.size < 2 || loading || incoming != null) return
|
||||
val next = (index + 1) % pictures.size
|
||||
load(next) { slide ->
|
||||
if (slide == null) {
|
||||
// A picture that cannot be read is passed over.
|
||||
index = next
|
||||
scheduleNext()
|
||||
} else {
|
||||
incoming = slide
|
||||
incomingIndex = next
|
||||
startFade()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFade() {
|
||||
if (!ValueAnimator.areAnimatorsEnabled() || !isShown || shown == null) {
|
||||
finishFade()
|
||||
return
|
||||
}
|
||||
fade = 0f
|
||||
fadeAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = FADE_MS
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener {
|
||||
fade = it.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) = finishFade()
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishFade() {
|
||||
fadeAnimator = null
|
||||
val slide = incoming ?: return
|
||||
shown = slide
|
||||
index = incomingIndex
|
||||
incoming = null
|
||||
fade = 0f
|
||||
updateDescription()
|
||||
invalidate()
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
private fun onTap() {
|
||||
when {
|
||||
// Nothing to show yet, as after a restore on a new phone: ask for pictures.
|
||||
pictures.isEmpty() -> choosePictures()
|
||||
// A tap does not wait for the picture's turn to end.
|
||||
pictures.size > 1 -> {
|
||||
removeCallbacks(advance)
|
||||
showNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun choosePictures() {
|
||||
val intent = Intent(context, PictureWidgetConfigActivity::class.java)
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
var activity: Context? = context
|
||||
while (activity != null && activity !is Activity) {
|
||||
activity = (activity as? ContextWrapper)?.baseContext
|
||||
}
|
||||
try {
|
||||
if (activity != null) {
|
||||
activity.startActivity(intent)
|
||||
} else {
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "The picture chooser could not be opened", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDescription() {
|
||||
contentDescription = when {
|
||||
sample -> resources.getString(R.string.flexera_picture_widget)
|
||||
pictures.isEmpty() -> resources.getString(R.string.flexera_picture_widget_empty)
|
||||
pictures.size == 1 -> resources.getString(R.string.flexera_picture_widget)
|
||||
else -> resources.getString(
|
||||
R.string.flexera_picture_widget_position,
|
||||
index + 1,
|
||||
pictures.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out where the picture goes inside this view. On the home screen that is the box
|
||||
* around the icons of the cells the widget covers; anywhere else it is the whole view.
|
||||
*/
|
||||
private fun updateFrame() {
|
||||
var left = 0f
|
||||
var top = 0f
|
||||
var right = width.toFloat()
|
||||
var bottom = height.toFloat()
|
||||
var corner = min(width, height) * LOOSE_CORNER_SHARE
|
||||
var iconSize = min(width, height).toFloat()
|
||||
|
||||
val host = parent as? View
|
||||
val lp = host?.layoutParams as? CellLayoutLayoutParams
|
||||
val container = host?.parent as? ShortcutAndWidgetContainer
|
||||
val profile = activityContext()?.deviceProfile
|
||||
if (host != null && lp != null && container != null && profile != null &&
|
||||
lp.cellHSpan > 0 && lp.cellVSpan > 0
|
||||
) {
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = PictureWidgetProvider.cellInset(host, profile.widgetPadding)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
|
||||
val gap = profile.cellLayoutBorderSpacePx
|
||||
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
|
||||
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
|
||||
|
||||
// Where an icon sits in its cell: centred sideways and a set distance below the
|
||||
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val iconLeft = (cellWidth - icon) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
|
||||
}
|
||||
// What is seen of an icon is a little smaller than the square it is drawn in; the
|
||||
// rest is room for its shadow.
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - getLeft()
|
||||
val r = spanLeft + spanWidth - iconLeft - air - getLeft()
|
||||
val t = spanTop + iconTop + air - getTop()
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - getTop()
|
||||
if (r - l >= 1f && b - t >= 1f) {
|
||||
left = l
|
||||
top = t
|
||||
right = r
|
||||
bottom = b
|
||||
corner = visible / 2f
|
||||
iconSize = icon
|
||||
}
|
||||
}
|
||||
corner = min(corner, min(right - left, bottom - top) / 2f)
|
||||
|
||||
if (left == frame.left && top == frame.top && right == frame.right && bottom == frame.bottom) return
|
||||
frame.set(left, top, right, bottom)
|
||||
|
||||
shape.reset()
|
||||
val corners = cornerShape
|
||||
if (corners != null) {
|
||||
CornerShapeCompat.addToPath(corners, shape, left, top, right, bottom, size = corner)
|
||||
} else {
|
||||
val radius = corner * currentIconShape().windowTransitionRadius
|
||||
shape.addRoundRect(frame, radius, radius, Path.Direction.CW)
|
||||
}
|
||||
shape.close()
|
||||
|
||||
// The same soft shadow an icon has (ShadowGenerator), so the picture sits on the
|
||||
// wallpaper the way the icons do.
|
||||
platePaint.setShadowLayer(
|
||||
iconSize * SHADOW_BLUR_SHARE,
|
||||
0f,
|
||||
iconSize * SHADOW_DROP_SHARE,
|
||||
SHADOW_COLOR,
|
||||
)
|
||||
buildSample()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** The drawing shown in place of a picture: a sun over two hills, and a hint when empty. */
|
||||
private fun buildSample() {
|
||||
val width = frame.width()
|
||||
val height = frame.height()
|
||||
val unit = min(width, height)
|
||||
|
||||
scratch.reset()
|
||||
scratch.moveTo(frame.left + width * 0.28f, frame.bottom)
|
||||
scratch.quadTo(frame.left + width * 0.66f, frame.bottom - unit * 0.80f, frame.right + width * 0.10f, frame.bottom)
|
||||
scratch.close()
|
||||
farHill.op(scratch, shape, Path.Op.INTERSECT)
|
||||
|
||||
scratch.reset()
|
||||
scratch.moveTo(frame.left - width * 0.10f, frame.bottom)
|
||||
scratch.quadTo(frame.left + width * 0.30f, frame.bottom - unit * 0.62f, frame.left + width * 0.74f, frame.bottom)
|
||||
scratch.close()
|
||||
nearHill.op(scratch, shape, Path.Op.INTERSECT)
|
||||
|
||||
scratch.reset()
|
||||
scratch.addCircle(frame.right - unit * 0.30f, frame.top + unit * 0.30f, unit * 0.10f, Path.Direction.CW)
|
||||
sun.op(scratch, shape, Path.Op.INTERSECT)
|
||||
|
||||
sampleShader = LinearGradient(
|
||||
frame.left,
|
||||
frame.top,
|
||||
frame.right,
|
||||
frame.bottom,
|
||||
SAMPLE_COLOR_START,
|
||||
SAMPLE_COLOR_END,
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
|
||||
hint = null
|
||||
val hintWidth = (width - 2 * resources.getDimension(R.dimen.flexera_picture_widget_hint_margin)).toInt()
|
||||
if (!sample && hintWidth > 0) {
|
||||
val text = resources.getString(R.string.flexera_picture_widget_empty)
|
||||
val layout = StaticLayout.Builder.obtain(text, 0, text.length, hintPaint, hintWidth)
|
||||
.setAlignment(Layout.Alignment.ALIGN_CENTER)
|
||||
.build()
|
||||
// Too small a widget for the words gets the drawing alone.
|
||||
val fits = layout.lineCount <= HINT_MAX_LINES && layout.height <= height * HINT_MAX_HEIGHT_SHARE
|
||||
if (fits) hint = layout
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (frame.isEmpty) return
|
||||
val current = shown
|
||||
if (current == null) {
|
||||
drawSample(canvas)
|
||||
return
|
||||
}
|
||||
// The plate carries the shadow. A picture with see-through parts goes without, so
|
||||
// that the wallpaper shows through it.
|
||||
if (!current.bitmap.hasAlpha()) canvas.drawPath(shape, platePaint)
|
||||
drawSlide(canvas, current, 255)
|
||||
incoming?.let { drawSlide(canvas, it, (fade * 255).roundToInt()) }
|
||||
}
|
||||
|
||||
private fun drawSlide(canvas: Canvas, slide: Slide, alpha: Int) {
|
||||
val bitmap = slide.bitmap
|
||||
// Fill the frame and crop what does not fit, keeping the middle of the picture.
|
||||
val scale = max(frame.width() / bitmap.width, frame.height() / bitmap.height)
|
||||
cropMatrix.setScale(scale, scale)
|
||||
cropMatrix.postTranslate(
|
||||
frame.left + (frame.width() - bitmap.width * scale) / 2f,
|
||||
frame.top + (frame.height() - bitmap.height * scale) / 2f,
|
||||
)
|
||||
slide.shader.setLocalMatrix(cropMatrix)
|
||||
picturePaint.shader = slide.shader
|
||||
picturePaint.alpha = alpha
|
||||
canvas.drawPath(shape, picturePaint)
|
||||
picturePaint.shader = null
|
||||
}
|
||||
|
||||
private fun drawSample(canvas: Canvas) {
|
||||
platePaint.shader = sampleShader
|
||||
canvas.drawPath(shape, platePaint)
|
||||
platePaint.shader = null
|
||||
|
||||
sceneryPaint.alpha = SUN_ALPHA
|
||||
canvas.drawPath(sun, sceneryPaint)
|
||||
sceneryPaint.alpha = FAR_HILL_ALPHA
|
||||
canvas.drawPath(farHill, sceneryPaint)
|
||||
sceneryPaint.alpha = NEAR_HILL_ALPHA
|
||||
canvas.drawPath(nearHill, sceneryPaint)
|
||||
|
||||
val layout = hint ?: return
|
||||
canvas.save()
|
||||
canvas.translate(
|
||||
frame.left + (frame.width() - layout.width) / 2f,
|
||||
frame.top + frame.height() * HINT_CENTRE_SHARE - layout.height / 2f,
|
||||
)
|
||||
layout.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun currentIconShape(): IconShape = PreferenceManager2.getInstance(context).iconShape.firstCached()
|
||||
|
||||
private fun activityContext(): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PictureWidgetView"
|
||||
|
||||
/** How long each picture of several is shown. */
|
||||
const val INTERVAL_MS = 10_000L
|
||||
|
||||
/** How long one picture takes to fade into the next. */
|
||||
const val FADE_MS = 600L
|
||||
|
||||
/** How long the size has to stay put during a resize before the picture is read anew. */
|
||||
const val RESIZE_SETTLE_MS = 200L
|
||||
|
||||
/** Corner radius, as a share of the shorter side, where there are no icons to match. */
|
||||
const val LOOSE_CORNER_SHARE = 0.18f
|
||||
|
||||
// An icon's shadow as Launcher3 draws it (ShadowGenerator.BLUR_FACTOR and
|
||||
// KEY_SHADOW_DISTANCE), as shares of the icon size.
|
||||
const val SHADOW_BLUR_SHARE = 1.68f / 48f
|
||||
const val SHADOW_DROP_SHARE = 1f / 48f
|
||||
const val SHADOW_COLOR = 0x26000000
|
||||
|
||||
/** Under a picture, where it shows only while the next one fades in. */
|
||||
const val PLATE_COLOR = 0xFF17181C.toInt()
|
||||
|
||||
const val SAMPLE_COLOR_START = 0xFF5B6B95.toInt()
|
||||
const val SAMPLE_COLOR_END = 0xFF2B3350.toInt()
|
||||
const val SCENERY_COLOR = 0xFFFFFFFF.toInt()
|
||||
const val SUN_ALPHA = 150
|
||||
const val FAR_HILL_ALPHA = 40
|
||||
const val NEAR_HILL_ALPHA = 64
|
||||
const val HINT_COLOR = 0xF2FFFFFF.toInt()
|
||||
const val HINT_MAX_LINES = 2
|
||||
const val HINT_MAX_HEIGHT_SHARE = 0.4f
|
||||
const val HINT_CENTRE_SHARE = 0.36f
|
||||
|
||||
/**
|
||||
* Puts a [PictureWidgetView] into a widget host view when the widget is the picture
|
||||
* widget, and returns it; returns null for every other widget. Launcher3's host view
|
||||
* calls this in place of asking Android for the widget's views.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun attachIfOwn(
|
||||
host: LauncherAppWidgetHostView,
|
||||
appWidgetId: Int,
|
||||
info: AppWidgetProviderInfo?,
|
||||
): View? {
|
||||
if (info == null || info.provider != PictureWidgetProvider.componentName) return null
|
||||
// A widget that still waits to be set up draws its own "tap to set up".
|
||||
if (host is PendingAppWidgetHostView) return null
|
||||
var view: PictureWidgetView? = null
|
||||
for (i in host.childCount - 1 downTo 0) {
|
||||
val child = host.getChildAt(i)
|
||||
if (child is PictureWidgetView && view == null) view = child else host.removeViewAt(i)
|
||||
}
|
||||
if (view == null) {
|
||||
view = PictureWidgetView(host.context)
|
||||
host.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.bind(appWidgetId)
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.3.1
|
||||
versionName=0.8.0
|
||||
@@ -128,6 +128,8 @@ coil-compose = "io.coil-kt:coil-compose:2.7.0"
|
||||
google-material = "com.google.android.material:material:1.14.0"
|
||||
hoko-blur = "io.github.hokofly:hoko-blur:1.5.5"
|
||||
javax-inject = "javax.inject:javax.inject:1"
|
||||
# Flexera Launcher: QR code on the About screen (app.flexera.ui.FlexeraAbout).
|
||||
zxing-core = "com.google.zxing:core:3.5.3"
|
||||
kdrag0n-colorkt = "dev.kdrag0n:colorkt:1.0.5"
|
||||
leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.14"
|
||||
leakcanary-androidstudio = "com.android.tools.studio.leakcanary:leakcanary:1.0.0"
|
||||
|
||||
@@ -214,6 +214,10 @@ class LawnchairApp : LauncherApplication() {
|
||||
val launcher: LawnchairLauncher?
|
||||
get() = activities.filterIsInstance<LawnchairLauncher>().firstOrNull()
|
||||
|
||||
// Flexera Launcher: see LawnchairApp.launchers.
|
||||
val launchers: List<LawnchairLauncher>
|
||||
get() = activities.filterIsInstance<LawnchairLauncher>()
|
||||
|
||||
fun finishAll() {
|
||||
HashSet(activities).forEach { it.finish() }
|
||||
}
|
||||
@@ -292,6 +296,14 @@ class LawnchairApp : LauncherApplication() {
|
||||
@JvmStatic
|
||||
val launcher: LawnchairLauncher? get() = instance.activityHandler.launcher
|
||||
|
||||
/**
|
||||
* Flexera Launcher: every copy of the launcher that is open. There are two when it was
|
||||
* opened like an app (from the installer's "Open", say) and then made the home app: one
|
||||
* in the app's own task and one as the home screen. [launcher] picks either of them.
|
||||
*/
|
||||
@JvmStatic
|
||||
val launchers: List<LawnchairLauncher> get() = instance.activityHandler.launchers
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
fun Launcher.showQuickstepWarningIfNecessary() {
|
||||
val launcher = this
|
||||
|
||||
@@ -517,7 +517,10 @@ class LawnchairLauncher : QuickstepLauncher() {
|
||||
|
||||
fun recreateIfNotScheduled() {
|
||||
if (sRestartFlags == 0) {
|
||||
recreate()
|
||||
// Flexera Launcher: every open copy, not only this one. A changed setting used to
|
||||
// rebuild whichever copy LawnchairApp.launcher happened to return, which could be
|
||||
// the one in the background, and the home screen kept its old look until restart.
|
||||
(LawnchairApp.launchers + this).distinct().forEach { it.recreate() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,11 +218,15 @@ class PreferenceManager @Inject constructor(
|
||||
val flexeraAlphabetOnLeft = BoolPref("pref_flexeraAlphabetOnLeft", false, recreate)
|
||||
|
||||
// Flexera Launcher: whether a slide along the home screen's edge opens the drawer at a letter (PRD AD-10).
|
||||
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false)
|
||||
// On unless switched off, by the owner's decision of 12 November 2026.
|
||||
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", true)
|
||||
|
||||
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
|
||||
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
|
||||
|
||||
// Flexera Launcher: how large the rows of the drawer list are, "compact" or "comfy" (FlexeraDrawer.RowSize).
|
||||
val flexeraDrawerRowSize = StringPref("pref_flexeraDrawerRowSize", "compact", recreate)
|
||||
|
||||
override fun close() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ package app.lawnchair.views
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import app.flexera.drawer.FlexeraDrawer
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.SystemUiController
|
||||
@@ -24,7 +25,10 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
|
||||
val threshold = STATUS_BAR_COLOR_FORCE_UPDATE_THRESHOLD
|
||||
val forceChange = visibility == VISIBLE &&
|
||||
alpha > threshold &&
|
||||
Color.alpha(mBackgroundColor) / (255f * drawerOpacity) > threshold
|
||||
// Flexera Launcher: the scrim is at full strength when it reaches the alpha of the
|
||||
// drawer's backdrop. Lawnchair measured against its drawer opacity setting, which
|
||||
// is 0 here.
|
||||
Color.alpha(mBackgroundColor) / Color.alpha(FlexeraDrawer.BACKDROP_COLOR).toFloat() > threshold
|
||||
with(systemUiController) {
|
||||
if (forceChange) {
|
||||
updateUiState(SystemUiController.UI_STATE_SCRIM_VIEW, !isScrimDark)
|
||||
@@ -34,9 +38,8 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
|
||||
}
|
||||
}
|
||||
|
||||
override fun isScrimDark() = if (drawerOpacity <= 0.3f) {
|
||||
!Themes.getAttrBoolean(context, R.attr.isWorkspaceDarkText)
|
||||
} else {
|
||||
super.isScrimDark()
|
||||
}
|
||||
// Flexera Launcher: judged by the scrim's own colour, as Launcher3 does. Lawnchair went by
|
||||
// the wallpaper here because its drawer scrim could be see-through; this drawer's backdrop
|
||||
// is always dark, and the status bar icons over it have to be light.
|
||||
override fun isScrimDark() = super.isScrimDark()
|
||||
}
|
||||
@@ -219,6 +219,9 @@ public class AllAppsState extends LauncherState {
|
||||
} else {
|
||||
backgroundColor = ColorTokens.AllAppsScrimColor.resolveColor(launcher);
|
||||
}
|
||||
// Flexera Launcher: the drawer's backdrop is dark whatever the theme and the wallpaper,
|
||||
// so that the white names on it stay readable.
|
||||
backgroundColor = app.flexera.drawer.FlexeraDrawer.BACKDROP_COLOR;
|
||||
return new ScrimColors(backgroundColor, /* foregroundColor */ Color.TRANSPARENT);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -98,7 +98,7 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
|
||||
private static final long FLEXERA_HOLD_STILL_MS = 150;
|
||||
// A finger that lifts sooner than this after the hold was noticed was not holding; see
|
||||
// onDragEnd. Too short a time to have read the letter and meant it.
|
||||
private static final long FLEXERA_MIN_HOLD_MS = 200;
|
||||
private static final long FLEXERA_MIN_HOLD_MS = app.flexera.drawer.FlexeraDrawer.MIN_SCRUB_MS;
|
||||
private long mFlexeraScrubStartTime;
|
||||
private boolean mStartedOverview;
|
||||
private boolean mReachedOverview;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,7 +150,10 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
DeviceProfile profile = mActivity.getDeviceProfile();
|
||||
final PointF appWidgetScale = profile.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace, profile.widgetPadding);
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the picture widget takes its cells whole.
|
||||
app.flexera.widget.PictureWidgetProvider.cellInset(child,
|
||||
profile.widgetPadding));
|
||||
} else {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
@@ -175,7 +178,9 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
if (child instanceof NavigableAppWidgetHostView) {
|
||||
final PointF appWidgetScale = dp.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace, dp.widgetPadding);
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the picture widget takes its cells whole.
|
||||
app.flexera.widget.PictureWidgetProvider.cellInset(child, dp.widgetPadding));
|
||||
} else if (isChildQsb(child)) {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
|
||||
private boolean mTrackingWidgetUpdate = false;
|
||||
|
||||
// Flexera Launcher: the view that draws this widget when it is one of the launcher's own
|
||||
// (the picture widget); null for every ordinary widget.
|
||||
@Nullable
|
||||
private View mFlexeraOwnView;
|
||||
|
||||
private int mFocusRectOutsets = 0;
|
||||
|
||||
public LauncherAppWidgetHostView(Context context) {
|
||||
@@ -131,6 +136,20 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
}
|
||||
}
|
||||
|
||||
// Flexera Launcher: Android puts a widget's stand-in layout (its initialLayout) into this
|
||||
// view whenever a widget without views has its colours reapplied, which Launcher3 does to
|
||||
// match the wallpaper. The launcher's own widget is drawn by mFlexeraOwnView and gets
|
||||
// nothing in that place.
|
||||
@Override
|
||||
protected View getDefaultView() {
|
||||
if (mFlexeraOwnView != null) {
|
||||
View nothing = new View(getContext());
|
||||
nothing.setVisibility(GONE);
|
||||
return nothing;
|
||||
}
|
||||
return super.getDefaultView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(View view) {
|
||||
if (mIsScrollable) {
|
||||
@@ -143,6 +162,12 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
@Override
|
||||
public void setAppWidget(int appWidgetId, AppWidgetProviderInfo info) {
|
||||
super.setAppWidget(appWidgetId, info);
|
||||
// Flexera Launcher: the launcher's own picture widget is drawn by a view of the
|
||||
// launcher, not by views Android sends over; nothing more is loaded for it.
|
||||
mFlexeraOwnView = app.flexera.widget.PictureWidgetView.attachIfOwn(this, appWidgetId, info);
|
||||
if (mFlexeraOwnView != null) {
|
||||
return;
|
||||
}
|
||||
if (!mTrackingWidgetUpdate && appWidgetId != -1) {
|
||||
mTrackingWidgetUpdate = true;
|
||||
Log.i(TAG, "App widget created with id: " + appWidgetId);
|
||||
@@ -154,6 +179,10 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
|
||||
@Override
|
||||
public void updateAppWidget(RemoteViews remoteViews) {
|
||||
// Flexera Launcher: see setAppWidget.
|
||||
if (mFlexeraOwnView != null) {
|
||||
return;
|
||||
}
|
||||
if (mTrackingWidgetUpdate && remoteViews != null) {
|
||||
Log.i(TAG, "App widget with id: " + getAppWidgetId() + " loaded");
|
||||
if (ATLEAST_Q) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_SIZES
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.SizeF
|
||||
import com.android.launcher3.dagger.ApplicationContext
|
||||
import com.android.launcher3.util.Executors
|
||||
@@ -46,13 +47,24 @@ open class WidgetSizeHandler @Inject constructor(@ApplicationContext private val
|
||||
spanY: Int,
|
||||
) {
|
||||
Executors.UI_HELPER_EXECUTOR.execute {
|
||||
val widgetManager = AppWidgetManager.getInstance(context)
|
||||
val sizeOptions = WidgetSizes.getWidgetSizeOptions(context, info.provider, spanX, spanY)
|
||||
if (
|
||||
sizeOptions.getWidgetSizeList() !=
|
||||
widgetManager.getAppWidgetOptions(widgetId).getWidgetSizeList()
|
||||
)
|
||||
widgetManager.updateAppWidgetOptions(widgetId, sizeOptions)
|
||||
// Flexera Launcher: a widget that still waits for "tap to set up" has an id that
|
||||
// Android has reserved but not yet tied to a provider. Android answers a size
|
||||
// update for such an id with a NullPointerException of its own (seen on Android 17:
|
||||
// AppWidgetServiceImpl.updateAppWidgetOptions), which arrives here and used to end
|
||||
// the launcher whenever the grid reloaded with such a widget on a home screen. That
|
||||
// is the normal state after a layout import, so the answer is logged and dropped;
|
||||
// the size is sent again when the widget is set up.
|
||||
try {
|
||||
val widgetManager = AppWidgetManager.getInstance(context)
|
||||
val sizeOptions = WidgetSizes.getWidgetSizeOptions(context, info.provider, spanX, spanY)
|
||||
if (
|
||||
sizeOptions.getWidgetSizeList() !=
|
||||
widgetManager.getAppWidgetOptions(widgetId).getWidgetSizeList()
|
||||
)
|
||||
widgetManager.updateAppWidgetOptions(widgetId, sizeOptions)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w("WidgetSizeHandler", "Android refused the size of widget $widgetId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user