Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
516c4efc56 | ||
|
|
8e6b737894 | ||
|
|
31ab0d52e1 | ||
|
|
04e78205dd | ||
|
|
cef6929a41 | ||
|
|
fba65e091f | ||
|
|
b696a376b2 | ||
|
|
bfc2a0ea36 | ||
|
|
fd1c8b195e | ||
|
|
5c4a6c346d | ||
|
|
d5a62cc6f5 |
No files matched your search
@@ -12,4 +12,58 @@
|
||||
<!-- Lets the app hand a downloaded update to Android's installer. The owner still confirms each install. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!--
|
||||
Since Android 17 an app needs this to reach a device on the network the phone itself is on.
|
||||
The update server can be such a device: at the owner's home its name leads to its address
|
||||
on the home network. Asked for only when a check for updates is stopped by it (About screen).
|
||||
-->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
<application>
|
||||
|
||||
<!--
|
||||
The picture widget that comes with the launcher. Not exported: only this launcher can
|
||||
place it, and only this launcher knows how to draw it.
|
||||
-->
|
||||
<receiver
|
||||
android:name="app.flexera.widget.PictureWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/flexera_picture_widget">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/flexera_picture_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- The clock widget that comes with the launcher: the time and the date. -->
|
||||
<receiver
|
||||
android:name="app.flexera.widget.ClockWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/flexera_clock_widget">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/flexera_clock_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- The clock widget's options, a sheet over the home screen. -->
|
||||
<activity
|
||||
android:name="app.flexera.widget.ClockWidgetConfigActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Transparent" />
|
||||
|
||||
<!-- Opens Android's photo picker for the picture widget; it has no screen of its own. -->
|
||||
<activity
|
||||
android:name="app.flexera.widget.PictureWidgetConfigActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Transparent" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+27
-10
@@ -211,6 +211,13 @@ if ($NoSmokeTest) {
|
||||
if ($LASTEXITCODE -ne 0 -or -not ($install -match 'Success')) {
|
||||
throw "Could not install the APK on $emulator. If an older build signed with another key is installed there, uninstall it first: adb uninstall $AppId"
|
||||
}
|
||||
# When this app is the emulator's home app, Android restarts it the moment the old copy is
|
||||
# gone, and that first start can die inside Android itself while the files are still being
|
||||
# swapped (a NullPointerException in ActivityThread.handleBindApplication, with no line of
|
||||
# this app in it). That is not the new build crashing: let the swap settle, then start
|
||||
# from a clean slate and judge what happens from there.
|
||||
Start-Sleep -Seconds 6
|
||||
& $Adb -s $emulator shell am force-stop $AppId
|
||||
& $Adb -s $emulator logcat -b crash -c
|
||||
& $Adb -s $emulator shell am start -n $HomeActivity | Out-Null
|
||||
Write-Host "Installed on $emulator and started; watching for $SmokeTestSeconds seconds..."
|
||||
@@ -244,23 +251,33 @@ $release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) '
|
||||
|
||||
Step "6/7 Uploading the APK"
|
||||
|
||||
$assetName = "FlexeraLauncher-$Version.apk"
|
||||
$stream = [IO.File]::OpenRead($apk)
|
||||
# The APK goes up twice. Once under a name with its version, which is what a person looking at
|
||||
# the release sees and what the app's updater downloads. And once under a name that never
|
||||
# changes, so that <server>/<repo>/releases/download/latest/FlexeraLauncher.apk always leads to
|
||||
# the newest APK: the address in the QR code on the app's About screen (FlexeraUpdater.LATEST_APK_URL).
|
||||
# The versioned one goes first, because the updater takes the first .apk it finds.
|
||||
$assetNames = @("FlexeraLauncher-$Version.apk", 'FlexeraLauncher.apk')
|
||||
$uploaded = @()
|
||||
try {
|
||||
$file = New-Object System.Net.Http.StreamContent($stream)
|
||||
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$form.Add($file, 'attachment', $assetName)
|
||||
$asset = Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) 'Uploading the APK'
|
||||
foreach ($assetName in $assetNames) {
|
||||
$stream = [IO.File]::OpenRead($apk)
|
||||
try {
|
||||
$file = New-Object System.Net.Http.StreamContent($stream)
|
||||
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$form.Add($file, 'attachment', $assetName)
|
||||
$uploaded += Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) "Uploading $assetName"
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# A release without its APK would make every installed copy report a failed check.
|
||||
$client.DeleteAsync("$Api/releases/$($release.id)").Result | Out-Null
|
||||
$client.DeleteAsync("$Api/tags/v$Version").Result | Out-Null
|
||||
throw
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
Write-Host "Uploaded: $($asset.browser_download_url)"
|
||||
$uploaded | ForEach-Object { Write-Host "Uploaded: $($_.browser_download_url)" }
|
||||
|
||||
# --- 7. Source tag -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A sun over a hill on a rounded plate: the stand-in for the picture widget's content. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="120"
|
||||
android:viewportHeight="120">
|
||||
<path
|
||||
android:fillColor="#FF3D4A70"
|
||||
android:pathData="M28,0h64a28,28 0,0 1,28 28v64a28,28 0,0 1,-28 28h-64a28,28 0,0 1,-28 -28v-64a28,28 0,0 1,28 -28z" />
|
||||
<path
|
||||
android:fillColor="#99FFFFFF"
|
||||
android:pathData="M84,36m-12,0a12,12 0,1 1,24 0a12,12 0,1 1,-24 0" />
|
||||
<path
|
||||
android:fillColor="#40FFFFFF"
|
||||
android:pathData="M0,120L0,104Q40,52 92,120Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What Android is told the clock widget looks like before it has anything to show: a plain
|
||||
clock that keeps time by itself. Flexera Launcher never shows it; it draws the widget itself
|
||||
(app.flexera.widget.ClockWidgetView).
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<TextClock
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:format12Hour="h:mm"
|
||||
android:format24Hour="H:mm"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="48sp" />
|
||||
|
||||
<TextClock
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:format12Hour="EEEE, d MMMM"
|
||||
android:format24Hour="EEEE, d MMMM"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What Android is told the picture widget looks like before it has anything to show. Flexera
|
||||
Launcher never shows it: it draws the widget itself (app.flexera.widget.PictureWidgetView).
|
||||
-->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/flexera_picture_widget_sample" />
|
||||
</FrameLayout>
|
||||
@@ -71,6 +71,10 @@
|
||||
<dimen name="flexera_drawer_text_shadow_radius">3dp</dimen>
|
||||
<dimen name="flexera_drawer_text_shadow_dy">1dp</dimen>
|
||||
|
||||
<!-- Picture widget: the words on a widget that has no pictures, and the room beside them. -->
|
||||
<dimen name="flexera_picture_widget_hint_text_size">13sp</dimen>
|
||||
<dimen name="flexera_picture_widget_hint_margin">10dp</dimen>
|
||||
|
||||
<dimen name="flexera_drawer_header_height">34dp</dimen>
|
||||
<dimen name="flexera_drawer_header_padding_bottom">4dp</dimen>
|
||||
<dimen name="flexera_drawer_header_text_size">12sp</dimen>
|
||||
|
||||
@@ -41,8 +41,13 @@
|
||||
</plurals>
|
||||
<string name="flexera_import_widgets">Widgets</string>
|
||||
<string name="flexera_import_widgets_value">%1$d. Each arrives in its place as “tap to set up”: Android asks you to allow it again, and its own settings start fresh.</string>
|
||||
<string name="flexera_import_photo_widgets">Photo widgets</string>
|
||||
<plurals name="flexera_import_photo_widgets_value">
|
||||
<item quantity="one">%1$d. It becomes Flexera Launcher\'s picture widget and keeps its photo. Tap it once after the import.</item>
|
||||
<item quantity="other">%1$d. They become Flexera Launcher\'s picture widgets and keep their photos. Tap each once after the import.</item>
|
||||
</plurals>
|
||||
<string name="flexera_import_left_out">Left out</string>
|
||||
<string name="flexera_import_own_widgets">Widgets of Smart Launcher itself</string>
|
||||
<string name="flexera_import_own_widgets">Other widgets of Smart Launcher itself</string>
|
||||
<plurals name="flexera_import_own_widgets_value">
|
||||
<item quantity="one">%1$d. It exists only inside Smart Launcher, so its place stays empty.</item>
|
||||
<item quantity="other">%1$d. They exist only inside Smart Launcher, so their places stay empty.</item>
|
||||
@@ -61,7 +66,7 @@
|
||||
<string name="flexera_import_unreadable">This is not a Smart Launcher backup that can be read</string>
|
||||
<string name="flexera_about_share">Share</string>
|
||||
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
|
||||
<string name="flexera_about_share_qr_description">QR code that opens the download page</string>
|
||||
<string name="flexera_about_share_qr_description">QR code that downloads the latest version</string>
|
||||
<string name="flexera_about_privacy">Privacy</string>
|
||||
<string name="flexera_about_privacy_title">Nothing about you leaves your phone</string>
|
||||
<string name="flexera_about_privacy_body">Flexera Launcher goes online for one thing: asking its release page whether a newer version exists, about once a day, and downloading that version when you ask. It sends nothing about you, your apps or your phone.</string>
|
||||
@@ -87,6 +92,36 @@
|
||||
<string name="flexera_scrub_from_home">Scrub from the home screen</string>
|
||||
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
|
||||
|
||||
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
|
||||
<string name="flexera_quick_launch">Quick launch</string>
|
||||
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
|
||||
<string name="flexera_quick_launch_first_page">Swipe past the first page</string>
|
||||
<string name="flexera_quick_launch_last_page">Swipe past the last page</string>
|
||||
|
||||
<!-- The clock widget that comes with the launcher (app.flexera.widget) -->
|
||||
<!-- Its name in the list of widgets -->
|
||||
<string name="flexera_clock_widget">Clock</string>
|
||||
<string name="flexera_clock_widget_description">The time and the date, lined up with your icons</string>
|
||||
<!-- The clock widget's options, reached from its edit button -->
|
||||
<string name="flexera_clock_widget_alignment">Alignment</string>
|
||||
<string name="flexera_clock_widget_left">Left</string>
|
||||
<string name="flexera_clock_widget_center">Center</string>
|
||||
<string name="flexera_clock_widget_right">Right</string>
|
||||
<string name="flexera_clock_widget_size">Size</string>
|
||||
<string name="flexera_clock_widget_small">Small</string>
|
||||
<string name="flexera_clock_widget_medium">Medium</string>
|
||||
<string name="flexera_clock_widget_large">Large</string>
|
||||
|
||||
<!-- 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>
|
||||
@@ -96,6 +131,7 @@
|
||||
<!-- 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_local_network">%1$s is on the same network as this phone, and Android lets an app reach devices there only with your permission, which it calls Nearby devices. Tap to allow it. The check then runs 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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Flexera Launcher's clock widget (app.flexera.widget.ClockWidgetProvider). Four cells by two
|
||||
when first placed, resizable from two by one upwards. The launcher draws it itself, so the
|
||||
layouts named here are never shown on the home screen; previewLayout is what makes the widget
|
||||
list ask for a live preview, which the launcher also draws. Nothing has to be set up when it
|
||||
is added (configuration_optional); its edit button opens the options for alignment and size.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
android:description="@string/flexera_clock_widget_description"
|
||||
android:configure="app.flexera.widget.ClockWidgetConfigActivity"
|
||||
android:initialLayout="@layout/flexera_clock_widget_placeholder"
|
||||
android:previewLayout="@layout/flexera_clock_widget_placeholder"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional" />
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Flexera Launcher's picture widget (app.flexera.widget.PictureWidgetProvider). Two cells by two
|
||||
when first placed, resizable down to one cell and up to the whole screen. The launcher draws it
|
||||
itself, so the layouts named here are never shown on the home screen; previewLayout is what
|
||||
makes the widget list ask for a live preview, which the launcher also draws.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="40dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
android:description="@string/flexera_picture_widget_description"
|
||||
android:configure="app.flexera.widget.PictureWidgetConfigActivity"
|
||||
android:initialLayout="@layout/flexera_picture_widget_placeholder"
|
||||
android:previewLayout="@layout/flexera_picture_widget_placeholder"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
@@ -9,6 +9,8 @@ import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import app.flexera.widget.PictureWidgetProvider
|
||||
import app.flexera.widget.PictureWidgetStore
|
||||
import app.lawnchair.DeviceProfileOverrides
|
||||
import app.lawnchair.backup.LawnchairBackup
|
||||
import app.lawnchair.preferences.PreferenceManager
|
||||
@@ -73,7 +75,9 @@ class SmartLauncherBackupConverter(
|
||||
val folders: Int,
|
||||
val appsInFolders: Int,
|
||||
val widgets: Int,
|
||||
/** Smart Launcher's own widgets, which stay behind. */
|
||||
/** Smart Launcher's photo widgets, which become the launcher's own picture widget. */
|
||||
val photoWidgets: Int,
|
||||
/** Smart Launcher's other widgets of its own, which stay behind. */
|
||||
val ownWidgets: Int,
|
||||
/** Other things that stay behind: no usable target, another profile, or no free cells. */
|
||||
val skipped: Int,
|
||||
@@ -98,6 +102,9 @@ class SmartLauncherBackupConverter(
|
||||
private data class HomeFolder(override val place: Place, val title: String?, val icons: List<Icon>) : HomeThing
|
||||
private data class HomeWidget(override val place: Place, val provider: String) : HomeThing
|
||||
|
||||
/** One of Smart Launcher's photo widgets, with the unpacked files of its pictures. */
|
||||
private data class HomePicture(override val place: Place, val photos: List<File>) : HomeThing
|
||||
|
||||
private data class Layout(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
@@ -115,6 +122,7 @@ class SmartLauncherBackupConverter(
|
||||
folders = things.count { it is HomeFolder },
|
||||
appsInFolders = things.sumOf { (it as? HomeFolder)?.icons?.size ?: 0 },
|
||||
widgets = things.count { it is HomeWidget },
|
||||
photoWidgets = things.count { it is HomePicture },
|
||||
ownWidgets = ownWidgets,
|
||||
skipped = skipped,
|
||||
)
|
||||
@@ -130,28 +138,9 @@ class SmartLauncherBackupConverter(
|
||||
* to show them.
|
||||
*/
|
||||
suspend fun convertAndRestore() {
|
||||
val layout = withContext(Dispatchers.IO) { withUnpackedBackup { dir -> readLayout(dir) } }
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempDir = newTempDir()
|
||||
try {
|
||||
val staged = File(tempDir, STAGED_DB)
|
||||
val shortcuts = writeLauncherDb(layout, staged)
|
||||
applyGrid(layout)
|
||||
|
||||
val restored = context.getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME)
|
||||
restored.parentFile?.mkdirs()
|
||||
staged.copyTo(restored, overwrite = true)
|
||||
// Opening the controller moves the restored file into place; the restore task
|
||||
// then marks every item as freshly restored, which is what makes Launcher3 ask
|
||||
// for the widgets anew and drop icons of apps that are not installed.
|
||||
check(RestoreDbTask.performRestore(context, ModelDbController(context))) {
|
||||
"Launcher3 did not accept the converted layout"
|
||||
}
|
||||
pinShortcuts(shortcuts)
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
val layout = withContext(Dispatchers.IO) {
|
||||
// The backup stays unpacked to the end: the photo widgets' pictures are read from it.
|
||||
withUnpackedBackup { dir -> readLayout(dir).also(::restore) }
|
||||
}
|
||||
|
||||
// Settings that make the result match the source: no dock, because Smart Launcher has
|
||||
@@ -161,6 +150,50 @@ class SmartLauncherBackupConverter(
|
||||
layout.showLabels?.let { prefs2.showIconLabelsOnHomeScreen.set(it) }
|
||||
}
|
||||
|
||||
/** Puts the layout into the launcher's database and keeps the photo widgets' pictures. */
|
||||
private fun restore(layout: Layout) {
|
||||
val tempDir = newTempDir()
|
||||
try {
|
||||
val staged = File(tempDir, STAGED_DB)
|
||||
val shortcuts = writeLauncherDb(layout, staged)
|
||||
applyGrid(layout)
|
||||
|
||||
val restored = context.getDatabasePath(LawnchairBackup.RESTORED_DB_FILE_NAME)
|
||||
restored.parentFile?.mkdirs()
|
||||
staged.copyTo(restored, overwrite = true)
|
||||
// Opening the controller moves the restored file into place; the restore task
|
||||
// then marks every item as freshly restored, which is what makes Launcher3 ask
|
||||
// for the widgets anew and drop icons of apps that are not installed.
|
||||
check(RestoreDbTask.performRestore(context, ModelDbController(context))) {
|
||||
"Launcher3 did not accept the converted layout"
|
||||
}
|
||||
pinShortcuts(shortcuts)
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
keepPictures(layout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the pictures of the imported photo widgets where the picture widget finds them. Each
|
||||
* widget arrives as "tap to set up" like every imported widget; the tap then gives it these
|
||||
* pictures in place of asking for some (PictureWidgetConfigActivity).
|
||||
*/
|
||||
private fun keepPictures(layout: Layout) {
|
||||
PictureWidgetStore.clearImports(context)
|
||||
for (thing in layout.things) {
|
||||
if (thing is HomePicture && thing.photos.isNotEmpty()) {
|
||||
PictureWidgetStore.stageImport(
|
||||
context,
|
||||
thing.place.screen,
|
||||
thing.place.x,
|
||||
thing.place.y,
|
||||
thing.photos.map(Uri::fromFile),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reading the backup ------------------------------------------------------------------
|
||||
|
||||
private fun <T> withUnpackedBackup(block: (File) -> T): T {
|
||||
@@ -177,23 +210,29 @@ class SmartLauncherBackupConverter(
|
||||
File(context.cacheDir, "$TEMP_DIR_PREFIX${UUID.randomUUID()}").apply { mkdirs() }
|
||||
|
||||
/**
|
||||
* Unpacks the database and the preference files, and nothing else, into [dir]. Entry names
|
||||
* come from the file and are not trusted: only the two known places are accepted, each file
|
||||
* lands under its bare name, and a file of unreasonable size ends the import.
|
||||
* Unpacks the database, the preference files and the pictures of the photo widgets, and
|
||||
* nothing else, into [dir]. Entry names come from the file and are not trusted: only the
|
||||
* known places are accepted, each file lands under a name made here, and a file of
|
||||
* unreasonable size ends the import.
|
||||
*/
|
||||
private fun unpack(dir: File) {
|
||||
val descriptor = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
?: error("Cannot open the backup file")
|
||||
descriptor.use {
|
||||
ZipInputStream(FileInputStream(it.fileDescriptor)).use { zip ->
|
||||
var photos = 0
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name
|
||||
val photo = SL_PHOTO_ENTRY.matchEntire(name)
|
||||
val target = when {
|
||||
entry.isDirectory -> null
|
||||
name == SL_DB_ENTRY -> File(dir, SL_DB)
|
||||
name.startsWith(SL_PREFS_DIR) && name.endsWith(".xml") ->
|
||||
File(dir, PREFS_PREFIX + File(name).name)
|
||||
// A photo widget's picture, kept under the number of its widget.
|
||||
photo != null && photos < MAX_PHOTOS ->
|
||||
File(dir, photoName(photo.groupValues[1], photos++))
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
@@ -227,7 +266,7 @@ class SmartLauncherBackupConverter(
|
||||
for (table in listOf(T_HOME, T_LAUNCHABLE, T_ACTION, T_WIDGET)) {
|
||||
require(tableExists(db, table)) { "Smart Launcher database without table $table" }
|
||||
}
|
||||
return readHomeScreens(db, grid)
|
||||
return readHomeScreens(db, grid, dir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +298,7 @@ class SmartLauncherBackupConverter(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?): Layout {
|
||||
private fun readHomeScreens(db: SQLiteDatabase, grid: Grid?, dir: File): Layout {
|
||||
data class Row(
|
||||
val launchable: Long?,
|
||||
val widget: Long?,
|
||||
@@ -311,12 +350,12 @@ class SmartLauncherBackupConverter(
|
||||
row.y + row.height <= gridRows && cells.none { it in taken }
|
||||
|
||||
val read: HomeThing? = when {
|
||||
row.widget != null -> readWidgetProvider(db, row.widget)?.let { HomeWidget(place, it) }
|
||||
row.widget != null -> readWidget(db, row.widget, place, dir)
|
||||
row.launchable != null -> readLaunchable(db, row.launchable, place)
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
// The only widgets without a provider this can use are Smart Launcher's own.
|
||||
// The only widgets with no counterpart here are Smart Launcher's own.
|
||||
read == null && row.widget != null -> ownWidgets++
|
||||
read == null || !fits -> skipped++
|
||||
else -> {
|
||||
@@ -336,8 +375,12 @@ class SmartLauncherBackupConverter(
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider of an ordinary app widget, or null for one of Smart Launcher's own. */
|
||||
private fun readWidgetProvider(db: SQLiteDatabase, id: Long): String? =
|
||||
/**
|
||||
* What a widget of the backup becomes. An ordinary app widget keeps its provider. Smart
|
||||
* Launcher's photo widget becomes the launcher's own picture widget, with the pictures the
|
||||
* backup holds for it. Smart Launcher's other widgets have no counterpart: null.
|
||||
*/
|
||||
private fun readWidget(db: SQLiteDatabase, id: Long, place: Place, dir: File): HomeThing? =
|
||||
db.rawQuery("SELECT type, provider, userId FROM $T_WIDGET WHERE id = ?", arrayOf(id.toString())).use { c ->
|
||||
if (!c.moveToFirst()) {
|
||||
null
|
||||
@@ -346,10 +389,17 @@ class SmartLauncherBackupConverter(
|
||||
val provider = if (c.isNull(1)) null else c.getString(1)
|
||||
val user = if (c.isNull(2)) OWNER_USER else c.getInt(2)
|
||||
val component = provider?.let(ComponentName::unflattenFromString)
|
||||
if (type != SL_WIDGET_TYPE_APP || component == null || user != OWNER_USER) {
|
||||
null
|
||||
} else {
|
||||
component.flattenToString()
|
||||
when {
|
||||
type == SL_WIDGET_TYPE_APP && component != null && user == OWNER_USER ->
|
||||
HomeWidget(place, component.flattenToString())
|
||||
|
||||
type != SL_WIDGET_TYPE_APP && provider?.endsWith(SL_PHOTO_WIDGET_CLASS) == true -> {
|
||||
val prefix = photoName(id.toString(), null)
|
||||
val photos = dir.listFiles { file -> file.name.startsWith(prefix) }
|
||||
HomePicture(place, photos?.sortedBy { it.name }.orEmpty())
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +569,14 @@ class SmartLauncherBackupConverter(
|
||||
itemType = Favorites.ITEM_TYPE_APPWIDGET,
|
||||
provider = thing.provider,
|
||||
)
|
||||
|
||||
is HomePicture -> insert(
|
||||
container = Favorites.CONTAINER_DESKTOP,
|
||||
place = thing.place,
|
||||
rank = 0,
|
||||
itemType = Favorites.ITEM_TYPE_APPWIDGET,
|
||||
provider = PictureWidgetProvider.componentName.flattenToString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
@@ -594,6 +652,21 @@ class SmartLauncherBackupConverter(
|
||||
/** `Widget.type` of an ordinary app widget; the other types are Smart Launcher's own. */
|
||||
const val SL_WIDGET_TYPE_APP = 1
|
||||
|
||||
/** How `Widget.provider` ends for Smart Launcher's photo widget, in every edition of it. */
|
||||
const val SL_PHOTO_WIDGET_CLASS = ".PhotoWidget"
|
||||
|
||||
/** Where a backup keeps the pictures of a photo widget: a folder named by `Widget.id`. */
|
||||
val SL_PHOTO_ENTRY = Regex("files/widgetPhoto/(\\d{1,18})/[^/]+")
|
||||
|
||||
/** More pictures than this in one backup are not taken along. */
|
||||
const val MAX_PHOTOS = 100
|
||||
|
||||
/**
|
||||
* The name an unpacked picture gets: its widget's number, then its turn. Without a turn,
|
||||
* the start of the names of all pictures of that widget.
|
||||
*/
|
||||
fun photoName(widget: String, turn: Int?): String = "photo_${widget}_" + (turn?.toString() ?: "")
|
||||
|
||||
/** Smart Launcher's user ids: the phone's owner, and "no user" on folders. */
|
||||
const val OWNER_USER = 0
|
||||
const val NO_USER = -1
|
||||
|
||||
@@ -140,6 +140,16 @@ private fun BackupContents(info: SmartLauncherBackupConverter.Info) {
|
||||
if (info.widgets > 0) {
|
||||
InfoRow(R.string.flexera_import_widgets, stringResource(id = R.string.flexera_import_widgets_value, info.widgets))
|
||||
}
|
||||
if (info.photoWidgets > 0) {
|
||||
InfoRow(
|
||||
R.string.flexera_import_photo_widgets,
|
||||
pluralStringResource(
|
||||
id = R.plurals.flexera_import_photo_widgets_value,
|
||||
count = info.photoWidgets,
|
||||
info.photoWidgets,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.ownWidgets > 0 || info.skipped > 0) {
|
||||
PreferenceGroup(heading = stringResource(id = R.string.flexera_import_left_out)) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package app.flexera.home
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.pm.LauncherApps
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import app.lawnchair.LawnchairLauncher
|
||||
import app.lawnchair.gestures.config.GestureHandlerConfig
|
||||
import app.lawnchair.gestures.handlers.OpenAppTarget
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.Launcher
|
||||
import com.android.launcher3.LauncherState
|
||||
import com.android.launcher3.util.EdgeEffectCompat
|
||||
|
||||
/**
|
||||
* Quick launch: a swipe on from the first home screen page, towards the page that is not there,
|
||||
* opens an app the owner has chosen (or does another of the launcher's gesture actions); the
|
||||
* same past the last page.
|
||||
*
|
||||
* The home screen already answers such a swipe by stretching and springing back. That stretch is
|
||||
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
|
||||
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
|
||||
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
|
||||
*/
|
||||
object QuickLaunch {
|
||||
|
||||
private const val TAG = "QuickLaunch"
|
||||
|
||||
/** How far the finger has to pull past the page, as a share of the screen's width. */
|
||||
private const val ARM_DISTANCE = 0.14f
|
||||
|
||||
/** A flick counts from this much pull, at [FLICK_DP_PER_SECOND] or faster. */
|
||||
private const val FLICK_DISTANCE = 0.02f
|
||||
private const val FLICK_DP_PER_SECOND = 600f
|
||||
|
||||
/**
|
||||
* The edge effect for one side of the home screen pages. [atLeftEdge] says which side of
|
||||
* the screen; which page that is the end of depends on the reading direction ([rtl]).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun edgeEffect(workspace: View, launcher: Launcher, atLeftEdge: Boolean, rtl: Boolean): EdgeEffectCompat =
|
||||
Effect(workspace, launcher, firstPage = atLeftEdge != rtl, atLeftEdge = atLeftEdge)
|
||||
|
||||
/** What the owner has chosen for a swipe past the first or the last page. */
|
||||
private fun choice(context: Context, firstPage: Boolean): GestureHandlerConfig {
|
||||
val prefs = PreferenceManager2.getInstance(context)
|
||||
return if (firstPage) {
|
||||
prefs.firstPageSwipeGestureHandler.firstCached()
|
||||
} else {
|
||||
prefs.lastPageSwipeGestureHandler.firstCached()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
|
||||
val choice = choice(launcher, firstPage)
|
||||
if (choice is GestureHandlerConfig.NoOp) return
|
||||
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
|
||||
if (target is OpenAppTarget.App) {
|
||||
// The app opens out of the screen edge the finger pulled from, as near to a page
|
||||
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
|
||||
// the launcher's own making only for the phone's built-in launcher.
|
||||
val root = launcher.rootView
|
||||
val options = ActivityOptions.makeClipRevealAnimation(
|
||||
root,
|
||||
if (fromLeft) 0 else root.width - 1,
|
||||
0,
|
||||
1,
|
||||
root.height,
|
||||
)
|
||||
try {
|
||||
launcher.getSystemService(LauncherApps::class.java)
|
||||
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
}
|
||||
return
|
||||
}
|
||||
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
|
||||
}
|
||||
|
||||
private class Effect(
|
||||
private val workspace: View,
|
||||
private val launcher: Launcher,
|
||||
private val firstPage: Boolean,
|
||||
private val atLeftEdge: Boolean,
|
||||
) : EdgeEffectCompat(workspace.context) {
|
||||
|
||||
/** How far the finger has pulled in this touch, as a share of the screen's width. */
|
||||
private var pulled = 0f
|
||||
|
||||
/** Whether there is anything to do on this side; looked up when a pull begins. */
|
||||
private var active = false
|
||||
private var armed = false
|
||||
private var flicked = false
|
||||
|
||||
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
|
||||
|
||||
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
|
||||
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
|
||||
if (pulled == 0f && deltaDistance > 0f) {
|
||||
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
|
||||
launcher.isInState(LauncherState.NORMAL) &&
|
||||
!launcher.dragController.isDragging
|
||||
}
|
||||
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
|
||||
val nowArmed = active && pulled >= ARM_DISTANCE
|
||||
if (nowArmed != armed) {
|
||||
armed = nowArmed
|
||||
// A tick when the pull is far enough, and again when it is taken back.
|
||||
tick()
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
|
||||
override fun onFlingVelocity(velocity: Int) {
|
||||
// A pull at the left side is a finger moving right, which is a positive speed.
|
||||
val outward = if (atLeftEdge) velocity else -velocity
|
||||
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
|
||||
}
|
||||
|
||||
override fun onRelease(ev: MotionEvent) {
|
||||
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
|
||||
val fire = lifted && active && (armed || flicked)
|
||||
val tickNow = fire && !armed
|
||||
reset()
|
||||
super.onRelease(ev)
|
||||
if (fire) {
|
||||
if (tickNow) tick()
|
||||
// After the page has finished handling this touch.
|
||||
workspace.post { launch(launcher, firstPage, fromLeft = atLeftEdge) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRelease() {
|
||||
reset()
|
||||
super.onRelease()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
reset()
|
||||
super.finish()
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
pulled = 0f
|
||||
active = false
|
||||
armed = false
|
||||
flicked = false
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
val feedback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE
|
||||
} else {
|
||||
HapticFeedbackConstants.CLOCK_TICK
|
||||
}
|
||||
// The pages themselves have haptics switched off; the window's root has not.
|
||||
launcher.rootView.performHapticFeedback(feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package app.flexera.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -196,7 +200,11 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
|
||||
groupRows(updateRows)
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
|
||||
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
|
||||
item {
|
||||
PreferenceGroupItem {
|
||||
ShareCard(scanUrl = FlexeraUpdater.LATEST_APK_URL, pageUrl = FlexeraUpdater.RELEASES_PAGE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
|
||||
groupRows(
|
||||
@@ -246,6 +254,21 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
private fun UpdateStatusRow(state: UpdateState) {
|
||||
val context = LocalContext.current
|
||||
// Android 17 asks the owner before an app may reach devices on the local network, and an
|
||||
// update server at home is one. The answer starts the check again; a "no" that Android no
|
||||
// longer asks about leads to the app's page in the phone's settings instead.
|
||||
val askForLocalNetwork = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
val activity = context.findActivity()
|
||||
if (granted || activity == null ||
|
||||
activity.shouldShowRequestPermissionRationale(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
|
||||
) {
|
||||
FlexeraUpdater.check(context)
|
||||
} else {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
when (state) {
|
||||
UpdateState.Idle -> ClickablePreference(
|
||||
label = stringResource(id = R.string.flexera_update_check),
|
||||
@@ -296,7 +319,13 @@ private fun UpdateStatusRow(state: UpdateState) {
|
||||
ClickablePreference(
|
||||
label = stringResource(id = R.string.flexera_update_check),
|
||||
subtitle = checkFailureText(state),
|
||||
onClick = { FlexeraUpdater.check(context) },
|
||||
onClick = {
|
||||
if (state.problem == CheckProblem.LOCAL_NETWORK) {
|
||||
askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
|
||||
} else {
|
||||
FlexeraUpdater.check(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ClickablePreference(
|
||||
@@ -316,11 +345,12 @@ 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.
|
||||
* What the owner holds up for someone who wants the app: a QR code that downloads the newest
|
||||
* APK ([scanUrl]), with the address of the release page under it for anyone who would rather
|
||||
* type one ([pageUrl]); that one is shorter and shows all versions.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareCard(url: String) {
|
||||
private fun ShareCard(scanUrl: String, pageUrl: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -328,7 +358,7 @@ private fun ShareCard(url: String) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
QrCode(
|
||||
text = url,
|
||||
text = scanUrl,
|
||||
description = stringResource(id = R.string.flexera_about_share_qr_description),
|
||||
modifier = Modifier.size(QR_CODE_SIZE),
|
||||
)
|
||||
@@ -340,7 +370,7 @@ private fun ShareCard(url: String) {
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = url.removePrefix("https://"),
|
||||
text = pageUrl.removePrefix("https://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -393,6 +423,7 @@ private fun checkFailureText(state: UpdateState.Failed): String {
|
||||
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
|
||||
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
|
||||
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
|
||||
CheckProblem.LOCAL_NETWORK -> stringResource(id = R.string.flexera_update_failed_local_network, host)
|
||||
CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
|
||||
}
|
||||
val detail = state.detail ?: return reason
|
||||
@@ -445,3 +476,21 @@ private fun openNotificationSettings(context: Context) {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens this app's page in the phone's settings, where its permissions are. */
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${context.packageName}".toUri())
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** The activity this context belongs to, if it belongs to one. */
|
||||
private fun Context.findActivity(): Activity? {
|
||||
var current: Context? = this
|
||||
while (current != null) {
|
||||
if (current is Activity) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
@@ -23,6 +25,8 @@ import com.android.launcher3.R
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.URL
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
@@ -74,6 +78,14 @@ enum class CheckProblem {
|
||||
/** The server was found but did not answer in time, or refused the connection. */
|
||||
NO_ANSWER,
|
||||
|
||||
/**
|
||||
* The server's address is on the network the phone itself is on (a server at home, reached
|
||||
* over the home Wi-Fi), and Android has not been told to let this app reach devices there.
|
||||
* Since Android 17 that takes a permission of its own; without it the connection is
|
||||
* dropped without a word and looks like a server that does not answer.
|
||||
*/
|
||||
LOCAL_NETWORK,
|
||||
|
||||
/** The encrypted connection could not be set up. */
|
||||
NOT_SECURE,
|
||||
|
||||
@@ -137,6 +149,13 @@ object FlexeraUpdater {
|
||||
/** The page listing every release, for the About screen's "All versions" link. */
|
||||
const val RELEASES_PAGE_URL = "$SERVER/$RELEASE_REPOSITORY/releases"
|
||||
|
||||
/**
|
||||
* Always the APK of the newest release: the server sends "latest" on to the newest release,
|
||||
* and every release carries its APK under this one name as well (flexera/release.ps1). What
|
||||
* the QR code on the About screen holds, so that scanning it starts the download.
|
||||
*/
|
||||
const val LATEST_APK_URL = "$SERVER/$RELEASE_REPOSITORY/releases/download/latest/FlexeraLauncher.apk"
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 15_000
|
||||
private const val READ_TIMEOUT_MS = 30_000
|
||||
|
||||
@@ -216,7 +235,12 @@ object FlexeraUpdater {
|
||||
nextAutomaticCheckAt = System.currentTimeMillis() + RETRY_INTERVAL_MS
|
||||
|
||||
scope.launch {
|
||||
val result = runCatching { fetchLatestRelease(app) }
|
||||
val result = runCatching {
|
||||
// Android drops a connection it does not allow without a word, and the check
|
||||
// would wait out its time limit. Say at once what is needed instead.
|
||||
if (blockedFromLocalNetwork(app)) throw LocalNetworkBlockedException()
|
||||
fetchLatestRelease(app)
|
||||
}
|
||||
prefs(app).edit()
|
||||
.putLong(KEY_LAST_ATTEMPT, System.currentTimeMillis())
|
||||
.putBoolean(KEY_LAST_ATTEMPT_OK, result.isSuccess)
|
||||
@@ -227,7 +251,7 @@ object FlexeraUpdater {
|
||||
Log.w(TAG, "Update check failed", error)
|
||||
// A background check that fails stays quiet: the About screen keeps what it showed
|
||||
// before, including an update that is already waiting. It tries again in an hour.
|
||||
mutableState.value = if (automatic) before else failedCheck(error)
|
||||
mutableState.value = if (automatic) before else failedCheck(app, error)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -346,17 +370,87 @@ object FlexeraUpdater {
|
||||
private class NoNetworkPermissionException : IOException("This app has no network permission")
|
||||
private class ServerErrorException(status: Int) : IOException("The server answered HTTP $status")
|
||||
private class BadAnswerException(message: String) : IOException(message)
|
||||
private class LocalNetworkBlockedException : IOException("Android does not let this app reach the local network")
|
||||
|
||||
/**
|
||||
* The permission Android 17 asks of an app before it may reach devices on the local
|
||||
* network. Named by its text, because older Android versions, which the app also runs on,
|
||||
* do not know it.
|
||||
*/
|
||||
const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
|
||||
|
||||
/** The first Android version that keeps apps from the local network: Android 17. */
|
||||
private const val LOCAL_NETWORK_SDK = 37
|
||||
|
||||
/**
|
||||
* True when Android is keeping this app from the update server because the server is on
|
||||
* the phone's own network and the owner has not allowed local network access. Looks the
|
||||
* server's name up, so not for the main thread.
|
||||
*/
|
||||
private fun blockedFromLocalNetwork(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < LOCAL_NETWORK_SDK) return false
|
||||
if (context.checkSelfPermission(LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED) return false
|
||||
return try {
|
||||
// Looking a name up is allowed without the permission, also at a resolver at home.
|
||||
InetAddress.getAllByName(URL(SERVER).host).any(::isOnOwnNetwork)
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
} catch (e: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an address that this phone reaches directly, without a router in between: one
|
||||
* inside the range of one of its own network connections. That is what Android counts as
|
||||
* the local network.
|
||||
*/
|
||||
private fun isOnOwnNetwork(address: InetAddress): Boolean {
|
||||
if (address.isLoopbackAddress) return false
|
||||
if (address.isLinkLocalAddress) return true
|
||||
val target = address.address
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
|
||||
for (network in interfaces) {
|
||||
if (network.isLoopback || !network.isUp) continue
|
||||
for (own in network.interfaceAddresses) {
|
||||
val base = own.address?.address ?: continue
|
||||
if (base.size != target.size) continue
|
||||
var bits = own.networkPrefixLength.toInt()
|
||||
if (bits <= 0) continue
|
||||
var same = true
|
||||
for (i in base.indices) {
|
||||
if (bits <= 0) break
|
||||
val mask = if (bits >= 8) 0xFF else (0xFF shl (8 - bits)) and 0xFF
|
||||
if ((base[i].toInt() and mask) != (target[i].toInt() and mask)) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
bits -= 8
|
||||
}
|
||||
if (same) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Turns the error of a failed check into a state that says what kind of failure it was. */
|
||||
private fun failedCheck(error: Throwable): UpdateState.Failed {
|
||||
val problem = when (error) {
|
||||
private fun failedCheck(context: Context, error: Throwable): UpdateState.Failed {
|
||||
val kind = when (error) {
|
||||
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
|
||||
is LocalNetworkBlockedException -> CheckProblem.LOCAL_NETWORK
|
||||
is UnknownHostException -> CheckProblem.SERVER_NOT_FOUND
|
||||
is SSLException -> CheckProblem.NOT_SECURE
|
||||
is ServerErrorException -> CheckProblem.SERVER_ERROR
|
||||
is BadAnswerException, is JSONException -> CheckProblem.BAD_ANSWER
|
||||
else -> CheckProblem.NO_ANSWER
|
||||
}
|
||||
// A server that did not answer may still be one that Android kept this app from, where
|
||||
// the look before the check could not tell (the network changed in between, say).
|
||||
val problem = if (kind == CheckProblem.NO_ANSWER && blockedFromLocalNetwork(context)) {
|
||||
CheckProblem.LOCAL_NETWORK
|
||||
} else {
|
||||
kind
|
||||
}
|
||||
// Android's own error classes keep their names in a release build; this file's are
|
||||
// renamed by the shrinker, and their messages say everything anyway.
|
||||
val name = error.javaClass.name.takeIf { it.startsWith("java") || it.startsWith("android") || it.startsWith("org.json") }
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.flexera.widget.ClockWidgetStore.Align
|
||||
import app.flexera.widget.ClockWidgetStore.Size
|
||||
import app.lawnchair.ui.theme.EdgeToEdge
|
||||
import app.lawnchair.ui.theme.LawnchairTheme
|
||||
import com.android.launcher3.R
|
||||
|
||||
/**
|
||||
* The clock widget's options: where its text stands and how large its figures are. Reached from
|
||||
* the widget's edit button. It is a sheet over the home screen, which stays in view, and every
|
||||
* choice shows on the widget at once; closing the sheet is all there is to finishing.
|
||||
*/
|
||||
class ClockWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val widget = intent?.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
if (widget == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// The widget is fine with or without a visit here.
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget))
|
||||
|
||||
setContent {
|
||||
LawnchairTheme {
|
||||
EdgeToEdge()
|
||||
var options by remember { mutableStateOf(ClockWidgetStore.options(this, widget)) }
|
||||
val change = { changed: ClockWidgetStore.Options ->
|
||||
options = changed
|
||||
ClockWidgetStore.set(this, widget, changed)
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { finish() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
// No dimming: the clock behind the sheet is what the choices are judged by.
|
||||
scrimColor = Color.Transparent,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.flexera_clock_widget),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_alignment,
|
||||
choices = listOf(
|
||||
Align.LEFT to R.string.flexera_clock_widget_left,
|
||||
Align.CENTER to R.string.flexera_clock_widget_center,
|
||||
Align.RIGHT to R.string.flexera_clock_widget_right,
|
||||
),
|
||||
chosen = options.align,
|
||||
onChoose = { change(options.copy(align = it)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_size,
|
||||
choices = listOf(
|
||||
Size.SMALL to R.string.flexera_clock_widget_small,
|
||||
Size.MEDIUM to R.string.flexera_clock_widget_medium,
|
||||
Size.LARGE to R.string.flexera_clock_widget_large,
|
||||
),
|
||||
chosen = options.size,
|
||||
onChoose = { change(options.copy(size = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A heading with one row of choices under it, of which one is taken. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun <T> Choice(
|
||||
@StringRes heading: Int,
|
||||
choices: List<Pair<T, Int>>,
|
||||
chosen: T,
|
||||
onChoose: (T) -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = heading),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
choices.forEachIndexed { index, (value, label) ->
|
||||
SegmentedButton(
|
||||
selected = value == chosen,
|
||||
onClick = { onChoose(value) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
|
||||
) {
|
||||
Text(text = stringResource(id = label))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import com.android.launcher3.BuildConfig
|
||||
|
||||
/**
|
||||
* The launcher's own clock widget: the time, large, with the date under it. Android knows it as
|
||||
* an ordinary app widget; the launcher draws it itself with a [ClockWidgetView]. It needs no
|
||||
* setting up; what the owner may choose for it is kept in [ClockWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class ClockWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
appWidgetIds.forEach { ClockWidgetStore.delete(context, it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, ClockWidgetProvider::class.java.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
/**
|
||||
* What the owner has chosen for each clock widget on the home screen: where its text stands and
|
||||
* how large its figures are. Kept per widget, so that two clocks can differ.
|
||||
*/
|
||||
object ClockWidgetStore {
|
||||
|
||||
/** Which side of the widget the time and the date stand on. */
|
||||
enum class Align(val key: String) { LEFT("left"), CENTER("center"), RIGHT("right") }
|
||||
|
||||
/** How large the figures are, measured against the icons (see ClockWidgetView). */
|
||||
enum class Size(val key: String) { SMALL("small"), MEDIUM("medium"), LARGE("large") }
|
||||
|
||||
data class Options(val align: Align = Align.LEFT, val size: Size = Size.MEDIUM)
|
||||
|
||||
/** Told, on the thread that made the change, that a widget's options are different now. */
|
||||
fun interface Listener {
|
||||
fun onClockOptionsChanged(appWidgetId: Int)
|
||||
}
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<Listener>()
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun options(context: Context, appWidgetId: Int): Options {
|
||||
val prefs = prefs(context)
|
||||
val align = prefs.getString(ALIGN + appWidgetId, null)
|
||||
val size = prefs.getString(SIZE + appWidgetId, null)
|
||||
return Options(
|
||||
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
|
||||
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
|
||||
)
|
||||
}
|
||||
|
||||
fun set(context: Context, appWidgetId: Int, options: Options) {
|
||||
prefs(context).edit()
|
||||
.putString(ALIGN + appWidgetId, options.align.key)
|
||||
.putString(SIZE + appWidgetId, options.size.key)
|
||||
.apply()
|
||||
listeners.forEach { it.onClockOptionsChanged(appWidgetId) }
|
||||
}
|
||||
|
||||
/** Forgets the options of a widget that left the home screen. */
|
||||
fun delete(context: Context, appWidgetId: Int) {
|
||||
prefs(context).edit().remove(ALIGN + appWidgetId).remove(SIZE + appWidgetId).apply()
|
||||
}
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
private const val FILE = "flexera_clock_widget"
|
||||
private const val ALIGN = "align_"
|
||||
private const val SIZE = "size_"
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.provider.AlarmClock
|
||||
import android.text.TextPaint
|
||||
import android.text.format.DateFormat
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.flexera.widget.ClockWidgetStore.Align
|
||||
import app.flexera.widget.ClockWidgetStore.Size
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.Themes
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* What the clock widget ([ClockWidgetProvider]) looks like: the time in light figures and the
|
||||
* date under it, written straight onto the wallpaper in the colour of the icon labels.
|
||||
*
|
||||
* Like the picture widget it keeps to the box of the icons in its cells ([IconFrame]). The
|
||||
* owner chooses which side of that box the text stands on, or its middle, and how large the
|
||||
* figures are ([ClockWidgetStore], set from the widget's edit button). The sizes are measured
|
||||
* against the icons, so that the clock keeps its proportion to them on any grid: small figures
|
||||
* are about half an icon tall, medium ones most of an icon, large ones taller than an icon. The
|
||||
* widget's own height and width are the limit in every case.
|
||||
*
|
||||
* A tap opens the phone's clock app.
|
||||
*/
|
||||
class ClockWidgetView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs),
|
||||
ClockWidgetStore.Listener {
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
/** True while this is only a sample of the widget, in the list of widgets. */
|
||||
private var sample = true
|
||||
private var options = ClockWidgetStore.Options()
|
||||
|
||||
private val frame = RectF()
|
||||
|
||||
/** What the figures are measured against: the height of what is seen of an icon. */
|
||||
private var iconHeight = 0f
|
||||
private val ink = Rect()
|
||||
private val now = Calendar.getInstance()
|
||||
private var time = ""
|
||||
private var date = ""
|
||||
|
||||
private val timePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = weight(TIME_WEIGHT, "sans-serif-light")
|
||||
// Figures of one width, so that the time does not shift sideways from minute to minute.
|
||||
fontFeatureSettings = "tnum"
|
||||
}
|
||||
private val datePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = weight(DATE_WEIGHT, "sans-serif-medium")
|
||||
}
|
||||
|
||||
// Where the two lines go; worked out in arrange().
|
||||
private var timeX = 0f
|
||||
private var timeBaseline = 0f
|
||||
private var dateX = 0f
|
||||
private var dateBaseline = 0f
|
||||
|
||||
private var listening = false
|
||||
private val clock = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) now.timeZone = TimeZone.getDefault()
|
||||
tell()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
// Says to Launcher3 that this widget shapes itself (see RoundedCornerEnforcement).
|
||||
id = android.R.id.background
|
||||
clipToOutline = true
|
||||
colour()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the home screen the text has the colour of the icon labels, which follows the
|
||||
* wallpaper. The list of widgets is a sheet of its own, so a sample there takes the sheet's
|
||||
* text colour.
|
||||
*/
|
||||
private fun colour() {
|
||||
val color = if (sample) {
|
||||
Themes.getAttrColor(context, android.R.attr.textColorPrimary)
|
||||
} else {
|
||||
Themes.getAttrColor(context, R.attr.workspaceTextColor)
|
||||
}
|
||||
timePaint.color = color
|
||||
datePaint.color = color
|
||||
// Light text on a wallpaper gets the soft shadow the icon labels have.
|
||||
if (!sample && Color.luminance(color) > 0.5f) {
|
||||
val density = resources.displayMetrics.density
|
||||
timePaint.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
|
||||
datePaint.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
|
||||
} else {
|
||||
timePaint.clearShadowLayer()
|
||||
datePaint.clearShadowLayer()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
|
||||
colour()
|
||||
if (sample) {
|
||||
// In the list of widgets a tap belongs to the list.
|
||||
setOnClickListener(null)
|
||||
isClickable = false
|
||||
} else {
|
||||
setOnClickListener { openClock() }
|
||||
}
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onClockOptionsChanged(appWidgetId: Int) {
|
||||
if (sample || appWidgetId != this.appWidgetId) return
|
||||
options = ClockWidgetStore.options(context, appWidgetId)
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
ClockWidgetStore.addListener(this)
|
||||
if (!listening) {
|
||||
val changes = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_TIME_TICK)
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
addAction(Intent.ACTION_LOCALE_CHANGED)
|
||||
}
|
||||
ContextCompat.registerReceiver(context, clock, changes, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
listening = true
|
||||
}
|
||||
now.timeZone = TimeZone.getDefault()
|
||||
if (!sample) options = ClockWidgetStore.options(context, appWidgetId)
|
||||
tell()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
ClockWidgetStore.removeListener(this)
|
||||
if (listening) {
|
||||
context.unregisterReceiver(clock)
|
||||
listening = false
|
||||
}
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onVisibilityAggregated(isVisible: Boolean) {
|
||||
super.onVisibilityAggregated(isVisible)
|
||||
// Android does not announce the minutes to an app in the background; catch up.
|
||||
if (isVisible) tell()
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
val aligned = IconFrame.of(this)
|
||||
if (aligned != null) {
|
||||
frame.set(aligned.left, aligned.top, aligned.right, aligned.bottom)
|
||||
iconHeight = aligned.visibleIconSize
|
||||
} else {
|
||||
// In the list of widgets: the whole view with a little air, and a made-up icon.
|
||||
val margin = min(width, height) * LOOSE_MARGIN_SHARE
|
||||
frame.set(margin, margin, width - margin, height - margin)
|
||||
iconHeight = frame.height() * LOOSE_ICON_SHARE
|
||||
}
|
||||
arrange()
|
||||
}
|
||||
|
||||
/** Reads the clock and shows what it says. */
|
||||
private fun tell() {
|
||||
now.timeInMillis = System.currentTimeMillis()
|
||||
val locale = Locale.getDefault()
|
||||
// The phone's own choice of 12 or 24 hours, without "AM" and "PM": the date line and
|
||||
// the daylight say which half of the day it is.
|
||||
val hours = if (DateFormat.is24HourFormat(context)) "Hm" else "hm"
|
||||
val timePattern = DateFormat.getBestDateTimePattern(locale, hours).replace("a", "").trim()
|
||||
val datePattern = DateFormat.getBestDateTimePattern(locale, "EEEEdMMMM")
|
||||
val newTime = DateFormat.format(timePattern, now).toString()
|
||||
val newDate = DateFormat.format(datePattern, now).toString()
|
||||
if (newTime == time && newDate == date) return
|
||||
time = newTime
|
||||
date = newDate
|
||||
contentDescription = "$time, $date"
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** Sizes the two lines for the frame and the owner's choices, and works out where they go. */
|
||||
private fun arrange() {
|
||||
val width = frame.width()
|
||||
val height = frame.height()
|
||||
if (width <= 0f || height <= 0f || time.isEmpty()) return
|
||||
val scaled = resources.displayMetrics.scaledDensity
|
||||
|
||||
// How tall the figures would like to be.
|
||||
val wanted = iconHeight * when (options.size) {
|
||||
Size.SMALL -> SMALL_IN_ICONS
|
||||
Size.MEDIUM -> MEDIUM_IN_ICONS
|
||||
Size.LARGE -> LARGE_IN_ICONS
|
||||
}
|
||||
|
||||
// The date goes with the figures, within the sizes that read well, and is never wider
|
||||
// than the frame.
|
||||
var dateSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
|
||||
datePaint.textSize = dateSize
|
||||
val dateWidth = datePaint.measureText(date)
|
||||
if (dateWidth > width) {
|
||||
dateSize *= width / dateWidth
|
||||
datePaint.textSize = dateSize
|
||||
}
|
||||
val dateMetrics = datePaint.fontMetrics
|
||||
val dateHeight = dateMetrics.descent - dateMetrics.ascent
|
||||
val gap = dateSize * GAP_IN_DATE_SIZES
|
||||
|
||||
// The figures: as tall as wanted, unless the room above the date or the frame's width
|
||||
// is less. Measured on the figures themselves, not on the line they stand in.
|
||||
timePaint.textSize = MEASURE_SIZE
|
||||
timePaint.getTextBounds(time, 0, time.length, ink)
|
||||
val inkHeightAtMeasure = max(1, ink.height()).toFloat()
|
||||
val inkWidthAtMeasure = max(1, ink.width()).toFloat()
|
||||
val figures = min(wanted, min(height - dateHeight - gap, width * inkHeightAtMeasure / inkWidthAtMeasure))
|
||||
timePaint.textSize = max(1f, MEASURE_SIZE * figures / inkHeightAtMeasure)
|
||||
timePaint.getTextBounds(time, 0, time.length, ink)
|
||||
|
||||
// Both lines together, centred between the top and the bottom of the frame.
|
||||
val block = ink.height() + gap + dateHeight
|
||||
val top = frame.top + (height - block) / 2f
|
||||
timeBaseline = top - ink.top
|
||||
dateBaseline = top + ink.height() + gap - dateMetrics.ascent
|
||||
|
||||
// Sideways it is the figures' own edges that meet the frame's, not the space the font
|
||||
// leaves beside them.
|
||||
val dateLength = datePaint.measureText(date)
|
||||
when (options.align) {
|
||||
Align.LEFT -> {
|
||||
timeX = frame.left - ink.left
|
||||
dateX = frame.left
|
||||
}
|
||||
|
||||
Align.CENTER -> {
|
||||
timeX = frame.centerX() - ink.exactCenterX()
|
||||
dateX = frame.centerX() - dateLength / 2f
|
||||
}
|
||||
|
||||
Align.RIGHT -> {
|
||||
timeX = frame.right - ink.right
|
||||
dateX = frame.right - dateLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (frame.isEmpty || time.isEmpty()) return
|
||||
canvas.drawText(time, timeX, timeBaseline, timePaint)
|
||||
canvas.drawText(date, dateX, dateBaseline, datePaint)
|
||||
}
|
||||
|
||||
private fun openClock() {
|
||||
val alarms = Intent(AlarmClock.ACTION_SHOW_ALARMS)
|
||||
try {
|
||||
// The clock app's own front door; its alarm list needs a permission of its own.
|
||||
val clockApp = context.packageManager.resolveActivity(alarms, 0)?.activityInfo?.packageName
|
||||
val intent = clockApp?.let(context.packageManager::getLaunchIntentForPackage) ?: return
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "The clock app could not be opened", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun weight(weight: Int, family: String): Typeface =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Typeface.create(Typeface.DEFAULT, weight, false)
|
||||
} else {
|
||||
Typeface.create(family, Typeface.NORMAL)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ClockWidgetView"
|
||||
|
||||
const val TIME_WEIGHT = 300
|
||||
const val DATE_WEIGHT = 500
|
||||
|
||||
/**
|
||||
* How tall the figures are for each size, in icon heights. (The first version filled
|
||||
* the widget, about 1.7 icons on a widget two rows high, which the owner found huge.)
|
||||
*/
|
||||
const val SMALL_IN_ICONS = 0.5f
|
||||
const val MEDIUM_IN_ICONS = 0.8f
|
||||
const val LARGE_IN_ICONS = 1.25f
|
||||
|
||||
/** The date's text size as a share of the figures' height, and its limits. */
|
||||
const val DATE_IN_FIGURES = 0.26f
|
||||
const val DATE_MIN_SP = 12f
|
||||
const val DATE_MAX_SP = 18f
|
||||
|
||||
/** The space between the figures and the date, in date text sizes. */
|
||||
const val GAP_IN_DATE_SIZES = 0.6f
|
||||
|
||||
const val MEASURE_SIZE = 200f
|
||||
const val LOOSE_MARGIN_SHARE = 0.12f
|
||||
const val LOOSE_ICON_SHARE = 0.45f
|
||||
|
||||
const val SHADOW_RADIUS_DP = 3f
|
||||
const val SHADOW_DROP_DP = 1f
|
||||
const val SHADOW_COLOR = 0x59000000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.ComponentName
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
|
||||
/**
|
||||
* The widgets that come with the launcher: Pictures ([PictureWidgetProvider]) and Clock
|
||||
* ([ClockWidgetProvider]).
|
||||
*
|
||||
* Android knows each as an ordinary app widget, so they are added, moved, resized and removed
|
||||
* like any other. What they show is not sent through Android, though: the launcher draws them
|
||||
* itself with a view of its own, which is what lets them line up with the icons ([IconFrame]).
|
||||
* This is where Launcher3's widget code asks about them.
|
||||
*/
|
||||
object FlexeraWidgets {
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
private fun isOwn(provider: ComponentName?): Boolean =
|
||||
provider == PictureWidgetProvider.componentName || provider == ClockWidgetProvider.componentName
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget the
|
||||
* same ([standard]); the launcher's own take none, because they place what they draw
|
||||
* against the icons of their cells by themselves.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (isOwn(info.providerName)) noInset else standard
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the launcher's own view into a widget host view when the widget is one of the
|
||||
* launcher's, and returns it; returns null for every other widget. Launcher3's host view
|
||||
* calls this in place of asking Android for the widget's views.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun attachIfOwn(host: LauncherAppWidgetHostView, appWidgetId: Int, info: AppWidgetProviderInfo?): View? {
|
||||
val provider = info?.provider
|
||||
if (!isOwn(provider)) return null
|
||||
// A widget that still waits to be set up draws its own "tap to set up".
|
||||
if (host is PendingAppWidgetHostView) return null
|
||||
val pictures = provider == PictureWidgetProvider.componentName
|
||||
|
||||
var view: View? = null
|
||||
for (i in host.childCount - 1 downTo 0) {
|
||||
val child = host.getChildAt(i)
|
||||
val wanted = if (pictures) child is PictureWidgetView else child is ClockWidgetView
|
||||
if (wanted && view == null) view = child else host.removeViewAt(i)
|
||||
}
|
||||
if (view == null) {
|
||||
view = if (pictures) PictureWidgetView(host.context) else ClockWidgetView(host.context)
|
||||
host.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
when (view) {
|
||||
is PictureWidgetView -> view.bind(appWidgetId)
|
||||
is ClockWidgetView -> view.bind(appWidgetId)
|
||||
}
|
||||
return view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import com.android.launcher3.ShortcutAndWidgetContainer
|
||||
import com.android.launcher3.celllayout.CellLayoutLayoutParams
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* The box around the icons that would sit in the cells a widget covers: from the left edge of
|
||||
* the first column's icon to the right edge of the last column's, and from the top of the first
|
||||
* row's icon to the bottom of the last row's. A widget of the launcher's own that keeps what it
|
||||
* draws inside this box lines up with the icons beside, above and below it.
|
||||
*/
|
||||
internal class IconFrame private constructor(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
/** The size of the square an icon is drawn in. */
|
||||
val iconSize: Float,
|
||||
/** The size of what is seen of an icon; the rest of the square is room for its shadow. */
|
||||
val visibleIconSize: Float,
|
||||
) {
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The box for the widget that [view] fills, in [view]'s own coordinates. Null when
|
||||
* [view] is not inside a widget on the home screen (in the list of widgets, say).
|
||||
*/
|
||||
fun of(view: View): IconFrame? {
|
||||
val host = view.parent as? View ?: return null
|
||||
val lp = host.layoutParams as? CellLayoutLayoutParams ?: return null
|
||||
val container = host.parent as? ShortcutAndWidgetContainer ?: return null
|
||||
val profile = activityContext(view.context)?.deviceProfile ?: return null
|
||||
if (lp.cellHSpan <= 0 || lp.cellVSpan <= 0) return null
|
||||
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = FlexeraWidgets.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)
|
||||
}
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - view.left
|
||||
val r = spanLeft + spanWidth - iconLeft - air - view.left
|
||||
val t = spanTop + iconTop + air - view.top
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - view.top
|
||||
if (r - l < 1f || b - t < 1f) return null
|
||||
return IconFrame(l, t, r, b, icon, visible)
|
||||
}
|
||||
|
||||
private fun activityContext(context: Context): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.android.launcher3.LauncherAppState
|
||||
import com.android.launcher3.LauncherSettings.Favorites
|
||||
import com.android.launcher3.R
|
||||
import kotlin.math.min
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Lets the owner choose the pictures of a picture widget. It has no screen of its own: it opens
|
||||
* Android's photo picker, copies what was chosen into the widget's storage and closes.
|
||||
*
|
||||
* Android starts it when the widget is put on the home screen and from the widget's edit
|
||||
* button; the widget starts it itself when it has no pictures. The photo picker hands over
|
||||
* only the pictures the owner picks, so the launcher needs no access to the gallery.
|
||||
*/
|
||||
class PictureWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
private val picker = registerForActivityResult(
|
||||
ActivityResultContracts.PickMultipleVisualMedia(mostPictures()),
|
||||
) { chosen -> onChosen(chosen) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
appWidgetId = intent?.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
// Leaving without pictures means "no": a widget that was just being added is dropped.
|
||||
setResult(RESULT_CANCELED, result())
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// After Android has restarted this screen, the picker is already open and answers here.
|
||||
if (savedInstanceState != null) return
|
||||
val widget = appWidgetId
|
||||
lifecycleScope.launch {
|
||||
// A widget that came with an import already has its pictures waiting.
|
||||
val adopted = withContext(Dispatchers.IO) { adoptImported(widget) }
|
||||
if (adopted) {
|
||||
setResult(RESULT_OK, result())
|
||||
finish()
|
||||
} else {
|
||||
picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the widget the pictures an import kept for its place on the home screen, if this is
|
||||
* such a widget and it has none yet. The launcher's database knows where the widget sits,
|
||||
* and an import keeps its pictures under that place.
|
||||
*/
|
||||
private fun adoptImported(widget: Int): Boolean {
|
||||
val context = applicationContext
|
||||
if (!PictureWidgetStore.hasImports(context)) return false
|
||||
if (PictureWidgetStore.pictures(context, widget).isNotEmpty()) return false
|
||||
return try {
|
||||
LauncherAppState.getInstance(context).model.modelDbController.query(
|
||||
arrayOf(Favorites.SCREEN, Favorites.CELLX, Favorites.CELLY),
|
||||
"${Favorites.APPWIDGET_ID} = ? AND ${Favorites.ITEM_TYPE} = ${Favorites.ITEM_TYPE_APPWIDGET} " +
|
||||
"AND ${Favorites.CONTAINER} = ${Favorites.CONTAINER_DESKTOP}",
|
||||
arrayOf(widget.toString()),
|
||||
null,
|
||||
).use { row ->
|
||||
row.moveToFirst() &&
|
||||
PictureWidgetStore.adoptImport(context, widget, row.getInt(0), row.getInt(1), row.getInt(2))
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Could not look for imported pictures", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun onChosen(chosen: List<Uri>) {
|
||||
if (chosen.isEmpty()) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Copying a few large photos takes a moment.
|
||||
setContentView(
|
||||
FrameLayout(this).apply {
|
||||
addView(
|
||||
ProgressBar(context),
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.CENTER,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val widget = appWidgetId
|
||||
lifecycleScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
PictureWidgetStore.replace(applicationContext, widget, chosen)
|
||||
}
|
||||
if (saved > 0) {
|
||||
setResult(RESULT_OK, result())
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this@PictureWidgetConfigActivity,
|
||||
R.string.flexera_picture_widget_unreadable,
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun result() = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PictureWidgetConfig"
|
||||
|
||||
/** How many pictures the photo picker lets the owner tick. */
|
||||
fun mostPictures(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
min(PictureWidgetStore.MAX_PICTURES, MediaStore.getPickImagesMaxLimit())
|
||||
} else {
|
||||
PictureWidgetStore.MAX_PICTURES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import com.android.launcher3.BuildConfig
|
||||
|
||||
/**
|
||||
* The launcher's own picture widget: one picture, or several that take turns.
|
||||
*
|
||||
* Android knows it as an ordinary app widget, so it is added, moved, resized and removed like
|
||||
* any other. What it shows is not sent through Android, though: the launcher draws it itself
|
||||
* with a [PictureWidgetView], which is what lets it line up with the icons around it. Its
|
||||
* pictures live in [PictureWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class PictureWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
appWidgetIds.forEach { PictureWidgetStore.delete(context, it) }
|
||||
}
|
||||
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
for (i in oldWidgetIds.indices) {
|
||||
val newId = newWidgetIds.getOrNull(i) ?: continue
|
||||
PictureWidgetStore.move(context, oldWidgetIds[i], newId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, PictureWidgetProvider::class.java.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Where the picture widget keeps its pictures: copies in the app's own storage, one folder per
|
||||
* widget on the home screen.
|
||||
*
|
||||
* Copies, because Android's photo picker lends a picture only for a while, and because a copy
|
||||
* needs no permission to read the gallery later. Each copy is scaled down to what a widget can
|
||||
* show, so a slideshow of large photos stays small.
|
||||
*/
|
||||
object PictureWidgetStore {
|
||||
|
||||
/** Told, on the main thread, that the pictures of a widget were replaced or removed. */
|
||||
fun interface Listener {
|
||||
fun onPicturesChanged(appWidgetId: Int)
|
||||
}
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<Listener>()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/** The pictures of one widget, in the order they were chosen. Empty when it has none. */
|
||||
fun pictures(context: Context, appWidgetId: Int): List<File> =
|
||||
folder(context, appWidgetId).listFiles { file -> file.isFile }?.sortedBy { it.name }.orEmpty()
|
||||
|
||||
/**
|
||||
* Gives a widget copies of [sources] in place of the pictures it had, and returns how many
|
||||
* were saved. A source that cannot be read is left out; when none can be read the widget
|
||||
* keeps what it had and the result is 0.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun replace(context: Context, appWidgetId: Int, sources: List<Uri>): Int {
|
||||
val incoming = File(root(context), "$appWidgetId$INCOMING_SUFFIX")
|
||||
var saved = saveCopies(context, incoming, sources)
|
||||
if (saved == 0) return 0
|
||||
val folder = folder(context, appWidgetId)
|
||||
folder.deleteRecursively()
|
||||
if (!incoming.renameTo(folder)) {
|
||||
incoming.deleteRecursively()
|
||||
saved = 0
|
||||
}
|
||||
notifyChanged(appWidgetId)
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps copies of [sources] for a picture widget that an import has put at a place on a
|
||||
* home screen. Such a widget has no id yet: Android gives it one when the owner taps it to
|
||||
* set it up, and [adoptImport] then hands it these pictures. Returns how many were saved.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun stageImport(context: Context, screen: Int, cellX: Int, cellY: Int, sources: List<Uri>): Int =
|
||||
saveCopies(context, importFolder(context, screen, cellX, cellY), sources)
|
||||
|
||||
/** True while pictures of an import wait for their widgets. */
|
||||
fun hasImports(context: Context): Boolean =
|
||||
root(context).listFiles { file -> file.isDirectory && file.name.startsWith(IMPORT_PREFIX) }
|
||||
?.isNotEmpty() == true
|
||||
|
||||
/** Forgets the pictures of an earlier import that were never claimed. */
|
||||
fun clearImports(context: Context) {
|
||||
root(context).listFiles { file -> file.isDirectory && file.name.startsWith(IMPORT_PREFIX) }
|
||||
?.forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives a widget the pictures an import kept for the place it sits at. True if there were
|
||||
* any; the widget then needs no pictures chosen for it.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun adoptImport(context: Context, appWidgetId: Int, screen: Int, cellX: Int, cellY: Int): Boolean {
|
||||
val staged = importFolder(context, screen, cellX, cellY)
|
||||
if (staged.listFiles { file -> file.isFile }.isNullOrEmpty()) return false
|
||||
val folder = folder(context, appWidgetId)
|
||||
folder.deleteRecursively()
|
||||
if (!staged.renameTo(folder)) return false
|
||||
notifyChanged(appWidgetId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Fills [into] with copies of [sources], replacing what it held. Returns how many were saved. */
|
||||
private fun saveCopies(context: Context, into: File, sources: List<Uri>): Int {
|
||||
into.deleteRecursively()
|
||||
if (!into.mkdirs()) return 0
|
||||
|
||||
var saved = 0
|
||||
for (source in sources.take(MAX_PICTURES)) {
|
||||
val bitmap = try {
|
||||
decode(context, source)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "A chosen picture could not be read", e)
|
||||
null
|
||||
} ?: continue
|
||||
// A picture with see-through parts keeps them; everything else is a photo.
|
||||
val keepsAlpha = isSeeThrough(bitmap)
|
||||
val name = String.format(Locale.ROOT, "%03d.%s", saved, if (keepsAlpha) "png" else "jpg")
|
||||
val file = File(into, name)
|
||||
val written = try {
|
||||
file.outputStream().use { out ->
|
||||
bitmap.compress(
|
||||
if (keepsAlpha) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG,
|
||||
JPEG_QUALITY,
|
||||
out,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "A chosen picture could not be saved", e)
|
||||
false
|
||||
}
|
||||
bitmap.recycle()
|
||||
if (written) saved++ else file.delete()
|
||||
}
|
||||
|
||||
if (saved == 0) into.deleteRecursively()
|
||||
return saved
|
||||
}
|
||||
|
||||
/** Removes the pictures of a widget that left the home screen. */
|
||||
fun delete(context: Context, appWidgetId: Int) {
|
||||
folder(context, appWidgetId).deleteRecursively()
|
||||
File(root(context), "$appWidgetId$INCOMING_SUFFIX").deleteRecursively()
|
||||
notifyChanged(appWidgetId)
|
||||
}
|
||||
|
||||
/** Hands the pictures of a widget to the id Android gave it after a restore. */
|
||||
fun move(context: Context, fromAppWidgetId: Int, toAppWidgetId: Int) {
|
||||
val from = folder(context, fromAppWidgetId)
|
||||
if (!from.isDirectory || fromAppWidgetId == toAppWidgetId) return
|
||||
val to = folder(context, toAppWidgetId)
|
||||
to.deleteRecursively()
|
||||
from.renameTo(to)
|
||||
notifyChanged(toAppWidgetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a stored picture for a frame of [width] by [height] pixels: at the smallest size
|
||||
* that still covers the frame, so that no more memory is used than the screen can show.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun load(file: File, width: Int, height: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= width && bounds.outHeight / (sample * 2) >= height) {
|
||||
sample *= 2
|
||||
}
|
||||
return BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
}
|
||||
|
||||
/** Reads a chosen picture, upright and no larger than [MAX_EDGE] on its long side. */
|
||||
private fun decode(context: Context, source: Uri): Bitmap? {
|
||||
val resolver = context.contentResolver
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
// ImageDecoder turns the picture the way the camera held it and knows the newer
|
||||
// formats (HEIC, AVIF).
|
||||
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, source)) { decoder, info, _ ->
|
||||
// The copy is compressed right after, which needs the pixels in ordinary memory.
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
val longEdge = max(info.size.width, info.size.height)
|
||||
if (longEdge > MAX_EDGE) {
|
||||
val scale = MAX_EDGE.toFloat() / longEdge
|
||||
decoder.setTargetSize(
|
||||
max(1, (info.size.width * scale).roundToInt()),
|
||||
max(1, (info.size.height * scale).roundToInt()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android 8: no ImageDecoder. Photos taken sideways stay sideways there.
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
var sample = 1
|
||||
while (max(bounds.outWidth, bounds.outHeight) / sample > MAX_EDGE) sample *= 2
|
||||
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
return resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, options) }
|
||||
}
|
||||
|
||||
/**
|
||||
* True if some of the picture lets the background through. Many pictures can hold such
|
||||
* parts without having any (every screenshot, for one), so the pixels are looked at.
|
||||
*/
|
||||
private fun isSeeThrough(bitmap: Bitmap): Boolean {
|
||||
if (!bitmap.hasAlpha()) return false
|
||||
val row = IntArray(bitmap.width)
|
||||
for (y in 0 until bitmap.height) {
|
||||
bitmap.getPixels(row, 0, bitmap.width, 0, y, bitmap.width, 1)
|
||||
if (row.any { it ushr 24 != 0xFF }) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun notifyChanged(appWidgetId: Int) {
|
||||
mainHandler.post { listeners.forEach { it.onPicturesChanged(appWidgetId) } }
|
||||
}
|
||||
|
||||
private fun root(context: Context) = File(context.filesDir, FOLDER)
|
||||
|
||||
private fun folder(context: Context, appWidgetId: Int) = File(root(context), appWidgetId.toString())
|
||||
|
||||
private fun importFolder(context: Context, screen: Int, cellX: Int, cellY: Int) =
|
||||
File(root(context), "$IMPORT_PREFIX$screen-$cellX-$cellY")
|
||||
|
||||
private const val TAG = "PictureWidgetStore"
|
||||
private const val FOLDER = "flexera_picture_widget"
|
||||
private const val INCOMING_SUFFIX = ".incoming"
|
||||
private const val IMPORT_PREFIX = "import-"
|
||||
|
||||
/** The most pictures one widget holds. */
|
||||
const val MAX_PICTURES = 50
|
||||
|
||||
/**
|
||||
* The long side of a stored picture, in pixels. A widget is never wider than the screen,
|
||||
* and a picture is cropped to fill it, so this leaves room to crop on any phone.
|
||||
*/
|
||||
private const val MAX_EDGE = 2048
|
||||
|
||||
private const val JPEG_QUALITY = 90
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import app.lawnchair.icons.shape.CornerShapeCompat
|
||||
import app.lawnchair.icons.shape.IconShape
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.Executors
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* What the picture widget ([PictureWidgetProvider]) looks like on the home screen: one picture
|
||||
* cropped to fill a frame, or several that take turns every [INTERVAL_MS].
|
||||
*
|
||||
* The frame is made to look like a large icon. Its edges are the outer edges of the icons that
|
||||
* would sit in the cells the widget covers, and its corners are the corners of the icon shape
|
||||
* in use, so the picture lines up with the icons beside, above and below it. The view itself is
|
||||
* larger than the frame: it fills the widget's cells and draws nothing outside the frame.
|
||||
*
|
||||
* In the list of widgets and while it is dragged from there, the same view shows a sample.
|
||||
*/
|
||||
class PictureWidgetView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs),
|
||||
PictureWidgetStore.Listener {
|
||||
|
||||
/** A picture ready to draw. */
|
||||
private class Slide(val bitmap: Bitmap) {
|
||||
val shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
}
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
/** True while this is only a sample of the widget, with no pictures of its own. */
|
||||
private var sample = true
|
||||
|
||||
private var pictures: List<File> = emptyList()
|
||||
private var index = 0
|
||||
private var shown: Slide? = null
|
||||
|
||||
// The picture that is fading in over the shown one, and its place among the pictures.
|
||||
private var incoming: Slide? = null
|
||||
private var incomingIndex = 0
|
||||
private var fade = 0f
|
||||
private var fadeAnimator: ValueAnimator? = null
|
||||
|
||||
// Pictures are read off the main thread. A result is used only if no newer read was started.
|
||||
private var loadGeneration = 0
|
||||
private var loading = false
|
||||
|
||||
/** The frame size the shown picture was read for; 0 before the first read. */
|
||||
private var loadedWidth = 0
|
||||
private var loadedHeight = 0
|
||||
|
||||
private var onScreen = false
|
||||
|
||||
private val frame = RectF()
|
||||
private val shape = Path()
|
||||
private val farHill = Path()
|
||||
private val nearHill = Path()
|
||||
private val sun = Path()
|
||||
private val scratch = Path()
|
||||
private val cropMatrix = Matrix()
|
||||
private var hint: StaticLayout? = null
|
||||
private var sampleShader: Shader? = null
|
||||
|
||||
private val picturePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
private val platePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = PLATE_COLOR }
|
||||
private val sceneryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = SCENERY_COLOR }
|
||||
private val hintPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = HINT_COLOR
|
||||
textSize = resources.getDimension(R.dimen.flexera_picture_widget_hint_text_size)
|
||||
}
|
||||
|
||||
private val advance = Runnable { showNext() }
|
||||
private val reloadForSize = Runnable { reload() }
|
||||
|
||||
// The icon shape, as corners that can be put on a rectangle of any size. Null for the few
|
||||
// shapes that are one fixed outline; those get plain round corners.
|
||||
private val cornerShape: IconShape.CornerBased? by lazy(LazyThreadSafetyMode.NONE) {
|
||||
when (val iconShape = currentIconShape()) {
|
||||
is IconShape.SystemBased -> iconShape.findNearestShape()
|
||||
is IconShape.CornerBased -> iconShape
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
// Launcher3 rounds the corners of every widget that does not round its own. This id
|
||||
// together with clipToOutline is how a widget says that it does (see
|
||||
// RoundedCornerEnforcement); without it the picture's corners would be cut twice.
|
||||
id = android.R.id.background
|
||||
clipToOutline = true
|
||||
}
|
||||
|
||||
/** Tells the view which widget it shows. Ids that Android never hands out mean a sample. */
|
||||
fun bind(appWidgetId: Int) {
|
||||
this.appWidgetId = appWidgetId
|
||||
sample = appWidgetId <= 0
|
||||
if (sample) {
|
||||
// In the list of widgets a tap belongs to the list.
|
||||
setOnClickListener(null)
|
||||
isClickable = false
|
||||
} else {
|
||||
setOnClickListener { onTap() }
|
||||
}
|
||||
index = 0
|
||||
// What the frame holds depends on whether this is a sample.
|
||||
frame.setEmpty()
|
||||
if (isLaidOut) updateFrame()
|
||||
if (isAttachedToWindow) reload()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
PictureWidgetStore.addListener(this)
|
||||
reload()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
PictureWidgetStore.removeListener(this)
|
||||
fadeAnimator?.cancel()
|
||||
removeCallbacks(advance)
|
||||
removeCallbacks(reloadForSize)
|
||||
// Whatever is still being read is no longer wanted.
|
||||
loadGeneration++
|
||||
loading = false
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onVisibilityAggregated(isVisible: Boolean) {
|
||||
super.onVisibilityAggregated(isVisible)
|
||||
// The pictures only take turns while the home screen can be seen.
|
||||
onScreen = isVisible
|
||||
if (isVisible) scheduleNext() else removeCallbacks(advance)
|
||||
}
|
||||
|
||||
override fun onPicturesChanged(appWidgetId: Int) {
|
||||
if (appWidgetId != this.appWidgetId || sample) return
|
||||
index = 0
|
||||
reload()
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
updateFrame()
|
||||
if (!isAttachedToWindow || frame.isEmpty || pictures.isEmpty()) return
|
||||
val width = frame.width().roundToInt()
|
||||
val height = frame.height().roundToInt()
|
||||
if (width == loadedWidth && height == loadedHeight) return
|
||||
removeCallbacks(reloadForSize)
|
||||
if (loadedWidth == 0) {
|
||||
// The first time the size is known.
|
||||
post(reloadForSize)
|
||||
} else {
|
||||
// The widget is being resized: read the picture anew once the size has settled.
|
||||
postDelayed(reloadForSize, RESIZE_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the widget's pictures anew and shows the one whose turn it is. */
|
||||
private fun reload() {
|
||||
fadeAnimator?.cancel()
|
||||
removeCallbacks(advance)
|
||||
removeCallbacks(reloadForSize)
|
||||
incoming = null
|
||||
pictures = if (sample) emptyList() else PictureWidgetStore.pictures(context, appWidgetId)
|
||||
updateDescription()
|
||||
if (pictures.isEmpty()) {
|
||||
loadGeneration++
|
||||
loading = false
|
||||
shown = null
|
||||
loadedWidth = 0
|
||||
loadedHeight = 0
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
if (frame.isEmpty) {
|
||||
// Not laid out yet; onLayout comes back here.
|
||||
loadedWidth = 0
|
||||
loadedHeight = 0
|
||||
return
|
||||
}
|
||||
index = index.coerceIn(0, pictures.lastIndex)
|
||||
loadedWidth = frame.width().roundToInt()
|
||||
loadedHeight = frame.height().roundToInt()
|
||||
load(index) { slide ->
|
||||
shown = slide
|
||||
invalidate()
|
||||
scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(position: Int, then: (Slide?) -> Unit) {
|
||||
val file = pictures.getOrNull(position)
|
||||
if (file == null) {
|
||||
then(null)
|
||||
return
|
||||
}
|
||||
val width = max(1, frame.width().roundToInt())
|
||||
val height = max(1, frame.height().roundToInt())
|
||||
val generation = ++loadGeneration
|
||||
loading = true
|
||||
Executors.THREAD_POOL_EXECUTOR.execute {
|
||||
val bitmap = try {
|
||||
PictureWidgetStore.load(file, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "A picture could not be read", e)
|
||||
null
|
||||
} catch (e: OutOfMemoryError) {
|
||||
Log.w(TAG, "A picture was too large to read", e)
|
||||
null
|
||||
}
|
||||
Executors.MAIN_EXECUTOR.execute {
|
||||
if (generation == loadGeneration) {
|
||||
loading = false
|
||||
then(bitmap?.let(::Slide))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleNext() {
|
||||
removeCallbacks(advance)
|
||||
if (onScreen && !sample && pictures.size > 1) postDelayed(advance, INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun showNext() {
|
||||
if (pictures.size < 2 || loading || incoming != null) return
|
||||
val next = (index + 1) % pictures.size
|
||||
load(next) { slide ->
|
||||
if (slide == null) {
|
||||
// A picture that cannot be read is passed over.
|
||||
index = next
|
||||
scheduleNext()
|
||||
} else {
|
||||
incoming = slide
|
||||
incomingIndex = next
|
||||
startFade()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFade() {
|
||||
if (!ValueAnimator.areAnimatorsEnabled() || !isShown || shown == null) {
|
||||
finishFade()
|
||||
return
|
||||
}
|
||||
fade = 0f
|
||||
fadeAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = FADE_MS
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener {
|
||||
fade = it.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) = finishFade()
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishFade() {
|
||||
fadeAnimator = null
|
||||
val slide = incoming ?: return
|
||||
shown = slide
|
||||
index = incomingIndex
|
||||
incoming = null
|
||||
fade = 0f
|
||||
updateDescription()
|
||||
invalidate()
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
private fun onTap() {
|
||||
when {
|
||||
// Nothing to show yet, as after a restore on a new phone: ask for pictures.
|
||||
pictures.isEmpty() -> choosePictures()
|
||||
// A tap does not wait for the picture's turn to end.
|
||||
pictures.size > 1 -> {
|
||||
removeCallbacks(advance)
|
||||
showNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun choosePictures() {
|
||||
val intent = Intent(context, PictureWidgetConfigActivity::class.java)
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
var activity: Context? = context
|
||||
while (activity != null && activity !is Activity) {
|
||||
activity = (activity as? ContextWrapper)?.baseContext
|
||||
}
|
||||
try {
|
||||
if (activity != null) {
|
||||
activity.startActivity(intent)
|
||||
} else {
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "The picture chooser could not be opened", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDescription() {
|
||||
contentDescription = when {
|
||||
sample -> resources.getString(R.string.flexera_picture_widget)
|
||||
pictures.isEmpty() -> resources.getString(R.string.flexera_picture_widget_empty)
|
||||
pictures.size == 1 -> resources.getString(R.string.flexera_picture_widget)
|
||||
else -> resources.getString(
|
||||
R.string.flexera_picture_widget_position,
|
||||
index + 1,
|
||||
pictures.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out where the picture goes inside this view. On the home screen that is the box
|
||||
* around the icons of the cells the widget covers; anywhere else it is the whole view.
|
||||
*/
|
||||
private fun updateFrame() {
|
||||
var left = 0f
|
||||
var top = 0f
|
||||
var right = width.toFloat()
|
||||
var bottom = height.toFloat()
|
||||
var corner = min(width, height) * LOOSE_CORNER_SHARE
|
||||
var iconSize = min(width, height).toFloat()
|
||||
|
||||
val aligned = IconFrame.of(this)
|
||||
if (aligned != null) {
|
||||
left = aligned.left
|
||||
top = aligned.top
|
||||
right = aligned.right
|
||||
bottom = aligned.bottom
|
||||
corner = aligned.visibleIconSize / 2f
|
||||
iconSize = aligned.iconSize
|
||||
}
|
||||
corner = min(corner, min(right - left, bottom - top) / 2f)
|
||||
|
||||
if (left == frame.left && top == frame.top && right == frame.right && bottom == frame.bottom) return
|
||||
frame.set(left, top, right, bottom)
|
||||
|
||||
shape.reset()
|
||||
val corners = cornerShape
|
||||
if (corners != null) {
|
||||
CornerShapeCompat.addToPath(corners, shape, left, top, right, bottom, size = corner)
|
||||
} else {
|
||||
val radius = corner * currentIconShape().windowTransitionRadius
|
||||
shape.addRoundRect(frame, radius, radius, Path.Direction.CW)
|
||||
}
|
||||
shape.close()
|
||||
|
||||
// The same soft shadow an icon has (ShadowGenerator), so the picture sits on the
|
||||
// wallpaper the way the icons do.
|
||||
platePaint.setShadowLayer(
|
||||
iconSize * SHADOW_BLUR_SHARE,
|
||||
0f,
|
||||
iconSize * SHADOW_DROP_SHARE,
|
||||
SHADOW_COLOR,
|
||||
)
|
||||
buildSample()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** The drawing shown in place of a picture: a sun over two hills, and a hint when empty. */
|
||||
private fun buildSample() {
|
||||
val width = frame.width()
|
||||
val height = frame.height()
|
||||
val unit = min(width, height)
|
||||
|
||||
scratch.reset()
|
||||
scratch.moveTo(frame.left + width * 0.28f, frame.bottom)
|
||||
scratch.quadTo(frame.left + width * 0.66f, frame.bottom - unit * 0.80f, frame.right + width * 0.10f, frame.bottom)
|
||||
scratch.close()
|
||||
farHill.op(scratch, shape, Path.Op.INTERSECT)
|
||||
|
||||
scratch.reset()
|
||||
scratch.moveTo(frame.left - width * 0.10f, frame.bottom)
|
||||
scratch.quadTo(frame.left + width * 0.30f, frame.bottom - unit * 0.62f, frame.left + width * 0.74f, frame.bottom)
|
||||
scratch.close()
|
||||
nearHill.op(scratch, shape, Path.Op.INTERSECT)
|
||||
|
||||
scratch.reset()
|
||||
scratch.addCircle(frame.right - unit * 0.30f, frame.top + unit * 0.30f, unit * 0.10f, Path.Direction.CW)
|
||||
sun.op(scratch, shape, Path.Op.INTERSECT)
|
||||
|
||||
sampleShader = LinearGradient(
|
||||
frame.left,
|
||||
frame.top,
|
||||
frame.right,
|
||||
frame.bottom,
|
||||
SAMPLE_COLOR_START,
|
||||
SAMPLE_COLOR_END,
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
|
||||
hint = null
|
||||
val hintWidth = (width - 2 * resources.getDimension(R.dimen.flexera_picture_widget_hint_margin)).toInt()
|
||||
if (!sample && hintWidth > 0) {
|
||||
val text = resources.getString(R.string.flexera_picture_widget_empty)
|
||||
val layout = StaticLayout.Builder.obtain(text, 0, text.length, hintPaint, hintWidth)
|
||||
.setAlignment(Layout.Alignment.ALIGN_CENTER)
|
||||
.build()
|
||||
// Too small a widget for the words gets the drawing alone.
|
||||
val fits = layout.lineCount <= HINT_MAX_LINES && layout.height <= height * HINT_MAX_HEIGHT_SHARE
|
||||
if (fits) hint = layout
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (frame.isEmpty) return
|
||||
val current = shown
|
||||
if (current == null) {
|
||||
drawSample(canvas)
|
||||
return
|
||||
}
|
||||
// The plate carries the shadow. A picture with see-through parts goes without, so
|
||||
// that the wallpaper shows through it.
|
||||
if (!current.bitmap.hasAlpha()) canvas.drawPath(shape, platePaint)
|
||||
drawSlide(canvas, current, 255)
|
||||
incoming?.let { drawSlide(canvas, it, (fade * 255).roundToInt()) }
|
||||
}
|
||||
|
||||
private fun drawSlide(canvas: Canvas, slide: Slide, alpha: Int) {
|
||||
val bitmap = slide.bitmap
|
||||
// Fill the frame and crop what does not fit, keeping the middle of the picture.
|
||||
val scale = max(frame.width() / bitmap.width, frame.height() / bitmap.height)
|
||||
cropMatrix.setScale(scale, scale)
|
||||
cropMatrix.postTranslate(
|
||||
frame.left + (frame.width() - bitmap.width * scale) / 2f,
|
||||
frame.top + (frame.height() - bitmap.height * scale) / 2f,
|
||||
)
|
||||
slide.shader.setLocalMatrix(cropMatrix)
|
||||
picturePaint.shader = slide.shader
|
||||
picturePaint.alpha = alpha
|
||||
canvas.drawPath(shape, picturePaint)
|
||||
picturePaint.shader = null
|
||||
}
|
||||
|
||||
private fun drawSample(canvas: Canvas) {
|
||||
platePaint.shader = sampleShader
|
||||
canvas.drawPath(shape, platePaint)
|
||||
platePaint.shader = null
|
||||
|
||||
sceneryPaint.alpha = SUN_ALPHA
|
||||
canvas.drawPath(sun, sceneryPaint)
|
||||
sceneryPaint.alpha = FAR_HILL_ALPHA
|
||||
canvas.drawPath(farHill, sceneryPaint)
|
||||
sceneryPaint.alpha = NEAR_HILL_ALPHA
|
||||
canvas.drawPath(nearHill, sceneryPaint)
|
||||
|
||||
val layout = hint ?: return
|
||||
canvas.save()
|
||||
canvas.translate(
|
||||
frame.left + (frame.width() - layout.width) / 2f,
|
||||
frame.top + frame.height() * HINT_CENTRE_SHARE - layout.height / 2f,
|
||||
)
|
||||
layout.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun currentIconShape(): IconShape = PreferenceManager2.getInstance(context).iconShape.firstCached()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PictureWidgetView"
|
||||
|
||||
/** How long each picture of several is shown. */
|
||||
const val INTERVAL_MS = 10_000L
|
||||
|
||||
/** How long one picture takes to fade into the next. */
|
||||
const val FADE_MS = 600L
|
||||
|
||||
/** How long the size has to stay put during a resize before the picture is read anew. */
|
||||
const val RESIZE_SETTLE_MS = 200L
|
||||
|
||||
/** Corner radius, as a share of the shorter side, where there are no icons to match. */
|
||||
const val LOOSE_CORNER_SHARE = 0.18f
|
||||
|
||||
// An icon's shadow as Launcher3 draws it (ShadowGenerator.BLUR_FACTOR and
|
||||
// KEY_SHADOW_DISTANCE), as shares of the icon size.
|
||||
const val SHADOW_BLUR_SHARE = 1.68f / 48f
|
||||
const val SHADOW_DROP_SHARE = 1f / 48f
|
||||
const val SHADOW_COLOR = 0x26000000
|
||||
|
||||
/** Under a picture, where it shows only while the next one fades in. */
|
||||
const val PLATE_COLOR = 0xFF17181C.toInt()
|
||||
|
||||
const val SAMPLE_COLOR_START = 0xFF5B6B95.toInt()
|
||||
const val SAMPLE_COLOR_END = 0xFF2B3350.toInt()
|
||||
const val SCENERY_COLOR = 0xFFFFFFFF.toInt()
|
||||
const val SUN_ALPHA = 150
|
||||
const val FAR_HILL_ALPHA = 40
|
||||
const val NEAR_HILL_ALPHA = 64
|
||||
const val HINT_COLOR = 0xF2FFFFFF.toInt()
|
||||
const val HINT_MAX_LINES = 2
|
||||
const val HINT_MAX_HEIGHT_SHARE = 0.4f
|
||||
const val HINT_CENTRE_SHARE = 0.36f
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
# build.gradle reads this and derives the Android version code from it
|
||||
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
|
||||
# flexera/release.ps1 raises it for every release.
|
||||
versionName=0.7.0
|
||||
versionName=0.10.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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -874,6 +874,18 @@ class PreferenceManager2 @Inject constructor(
|
||||
defaultValue = GestureHandlerConfig.NoOp,
|
||||
)
|
||||
|
||||
// Flexera Launcher: quick launch. What a swipe on from the first home screen page, towards
|
||||
// the page that is not there, does; and the same past the last page (app.flexera.home.QuickLaunch).
|
||||
val firstPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
|
||||
key = stringPreferencesKey("flexera_first_page_swipe_gesture_handler"),
|
||||
defaultValue = GestureHandlerConfig.NoOp,
|
||||
)
|
||||
|
||||
val lastPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
|
||||
key = stringPreferencesKey("flexera_last_page_swipe_gesture_handler"),
|
||||
defaultValue = GestureHandlerConfig.NoOp,
|
||||
)
|
||||
|
||||
val autoUpdaterNightly = preference(
|
||||
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
|
||||
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.lawnchair.ui.preferences.destinations
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -53,6 +54,23 @@ fun GesturePreferences(
|
||||
label = stringResource(id = R.string.gesture_back_tap),
|
||||
)
|
||||
}
|
||||
// Flexera Launcher: quick launch, a swipe on past the first or the last home screen page
|
||||
// (app.flexera.home.QuickLaunch). It rides on Android's own page stretch, new in Android 12.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PreferenceGroup(
|
||||
heading = stringResource(id = R.string.flexera_quick_launch),
|
||||
description = stringResource(id = R.string.flexera_quick_launch_description),
|
||||
) {
|
||||
GestureHandlerPreference(
|
||||
adapter = prefs.firstPageSwipeGestureHandler.getAdapter(),
|
||||
label = stringResource(id = R.string.flexera_quick_launch_first_page),
|
||||
)
|
||||
GestureHandlerPreference(
|
||||
adapter = prefs.lastPageSwipeGestureHandler.getAdapter(),
|
||||
label = stringResource(id = R.string.flexera_quick_launch_last_page),
|
||||
)
|
||||
}
|
||||
}
|
||||
PreferenceGroup(heading = stringResource(id = R.string.sleep_mode_label)) {
|
||||
ListPreference(
|
||||
adapter = prefs.sleepMode.getAdapter(),
|
||||
|
||||
@@ -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 launcher's own widgets take their cells whole.
|
||||
app.flexera.widget.FlexeraWidgets.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 launcher's own widgets take their cells whole.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, dp.widgetPadding));
|
||||
} else if (isChildQsb(child)) {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
|
||||
@@ -372,6 +372,13 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
|
||||
|
||||
setHapticFeedbackEnabled(false);
|
||||
initWorkspace();
|
||||
// Flexera Launcher: quick launch. The stretch at either end of the pages doubles as a
|
||||
// gesture that opens an app the owner has chosen (app.flexera.home.QuickLaunch). From
|
||||
// Android 12, where that stretch is Android's own; before, Lawnchair draws one itself.
|
||||
if (Utilities.ATLEAST_S) {
|
||||
mEdgeGlowLeft = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, true, mIsRtl);
|
||||
mEdgeGlowRight = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, false, mIsRtl);
|
||||
}
|
||||
|
||||
// Disable multitouch across the workspace/all apps/customize tray
|
||||
setMotionEventSplittingEnabled(true);
|
||||
@@ -1476,7 +1483,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
|
||||
public void setLauncherOverlay(LauncherOverlayTouchProxy overlay) {
|
||||
final EdgeEffectCompat newEffect;
|
||||
if (overlay == null) {
|
||||
newEffect = new EdgeEffectCompat(getContext());
|
||||
// Flexera Launcher: without a feed, the first page's end is quick launch again.
|
||||
newEffect = Utilities.ATLEAST_S
|
||||
? app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, !mIsRtl, mIsRtl)
|
||||
: new EdgeEffectCompat(getContext());
|
||||
mOverlayEdgeEffect = null;
|
||||
} else {
|
||||
newEffect = mOverlayEdgeEffect = new OverlayEdgeEffect(getContext(), overlay);
|
||||
|
||||
@@ -87,6 +87,11 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
|
||||
private boolean mTrackingWidgetUpdate = false;
|
||||
|
||||
// Flexera Launcher: the view that draws this widget when it is one of the launcher's own
|
||||
// (app.flexera.widget.FlexeraWidgets); null for every ordinary widget.
|
||||
@Nullable
|
||||
private View mFlexeraOwnView;
|
||||
|
||||
private int mFocusRectOutsets = 0;
|
||||
|
||||
public LauncherAppWidgetHostView(Context context) {
|
||||
@@ -131,6 +136,20 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
}
|
||||
}
|
||||
|
||||
// Flexera Launcher: Android puts a widget's stand-in layout (its initialLayout) into this
|
||||
// view whenever a widget without views has its colours reapplied, which Launcher3 does to
|
||||
// match the wallpaper. The launcher's own widget is drawn by mFlexeraOwnView and gets
|
||||
// nothing in that place.
|
||||
@Override
|
||||
protected View getDefaultView() {
|
||||
if (mFlexeraOwnView != null) {
|
||||
View nothing = new View(getContext());
|
||||
nothing.setVisibility(GONE);
|
||||
return nothing;
|
||||
}
|
||||
return super.getDefaultView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(View view) {
|
||||
if (mIsScrollable) {
|
||||
@@ -143,6 +162,12 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
@Override
|
||||
public void setAppWidget(int appWidgetId, AppWidgetProviderInfo info) {
|
||||
super.setAppWidget(appWidgetId, info);
|
||||
// Flexera Launcher: the launcher's own widgets (pictures, clock) are drawn by a view of
|
||||
// the launcher, not by views Android sends over; nothing more is loaded for them.
|
||||
mFlexeraOwnView = app.flexera.widget.FlexeraWidgets.attachIfOwn(this, appWidgetId, info);
|
||||
if (mFlexeraOwnView != null) {
|
||||
return;
|
||||
}
|
||||
if (!mTrackingWidgetUpdate && appWidgetId != -1) {
|
||||
mTrackingWidgetUpdate = true;
|
||||
Log.i(TAG, "App widget created with id: " + appWidgetId);
|
||||
@@ -154,6 +179,10 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
|
||||
@Override
|
||||
public void updateAppWidget(RemoteViews remoteViews) {
|
||||
// Flexera Launcher: see setAppWidget.
|
||||
if (mFlexeraOwnView != null) {
|
||||
return;
|
||||
}
|
||||
if (mTrackingWidgetUpdate && remoteViews != null) {
|
||||
Log.i(TAG, "App widget with id: " + getAppWidgetId() + " loaded");
|
||||
if (ATLEAST_Q) {
|
||||
|
||||
Reference in new issue
Block a user