Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8da9f301c | ||
|
|
77278a11e0 | ||
|
|
81c5c8914a | ||
|
|
3b7388c473 | ||
|
|
4f687957fb | ||
|
|
b79579a8dd | ||
|
|
516c4efc56 | ||
|
|
8e6b737894 | ||
|
|
31ab0d52e1 |
No files matched your search
@@ -37,6 +37,26 @@
|
||||
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"
|
||||
|
||||
+20
-10
@@ -251,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,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Hide app" in an app's long-press menu: an eye with a line through it. Drawn as strokes;
|
||||
the menu tints it like its other icons. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- The eye's outline, open on the side the line crosses first. -->
|
||||
<path
|
||||
android:pathData="M9.9,5.2C10.6,5.07 11.3,5 12,5C16.2,5 19.7,7.6 21.5,12C20.9,13.4 20.1,14.6 19.1,15.6M16.4,17.6C15.1,18.5 13.6,19 12,19C7.8,19 4.3,16.4 2.5,12C3.3,10 4.5,8.4 6,7.2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="1.8"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
<!-- What the line leaves of the pupil. -->
|
||||
<path
|
||||
android:pathData="M14.1,14.1C13.56,14.66 12.82,15 12,15C10.34,15 9,13.66 9,12C9,11.18 9.34,10.44 9.9,9.9"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="1.8"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
<!-- The line. -->
|
||||
<path
|
||||
android:pathData="M4,4L20,20"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="1.8"
|
||||
android:strokeLineCap="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What Android is told the clock widget looks like before it has anything to show: a plain
|
||||
clock that keeps time by itself. Flexera Launcher never shows it; it draws the widget itself
|
||||
(app.flexera.widget.ClockWidgetView).
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<TextClock
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:format12Hour="h:mm"
|
||||
android:format24Hour="H:mm"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="48sp" />
|
||||
|
||||
<TextClock
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:format12Hour="EEEE, d MMMM"
|
||||
android:format24Hour="EEEE, d MMMM"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
@@ -66,10 +66,10 @@
|
||||
<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>
|
||||
<string name="flexera_about_privacy_title">What goes online</string>
|
||||
<string name="flexera_about_privacy_body">Flexera Launcher goes online for two things. It asks its release page whether a newer version exists, about once a day, and downloads that version when you ask. And if you pick a place for the weather on a clock widget, it asks Open-Meteo, a weather service, for the weather there; that place is all it sends. Nothing else about you, your apps or your phone leaves it.</string>
|
||||
|
||||
<!-- Lawnchair's wording promised web results and grid settings that this app does not have -->
|
||||
<string name="all_apps_device_search_hint">Search apps and more</string>
|
||||
@@ -93,11 +93,59 @@
|
||||
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
|
||||
|
||||
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
|
||||
<string name="flexera_hide_app">Hide app</string>
|
||||
<!-- Shown after an app was hidden from the app list. %1$s is the app's name -->
|
||||
<string name="flexera_app_hidden">%1$s is hidden</string>
|
||||
<string name="flexera_folder_blur">Blur behind open folders</string>
|
||||
<string name="flexera_folder_blur_description">Blurs the home screen while a folder is open</string>
|
||||
<string name="flexera_quick_launch">Quick launch</string>
|
||||
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
|
||||
<string name="flexera_quick_launch_first_page">Swipe past the first page</string>
|
||||
<string name="flexera_quick_launch_last_page">Swipe past the last page</string>
|
||||
|
||||
<!-- The clock widget that comes with the launcher (app.flexera.widget) -->
|
||||
<!-- Its name in the list of widgets -->
|
||||
<string name="flexera_clock_widget">Clock</string>
|
||||
<string name="flexera_clock_widget_description">The time and the date, lined up with your icons</string>
|
||||
<!-- The clock widget's options, reached from its edit button -->
|
||||
<string name="flexera_clock_widget_alignment">Alignment</string>
|
||||
<string name="flexera_clock_widget_left">Left</string>
|
||||
<string name="flexera_clock_widget_center">Center</string>
|
||||
<string name="flexera_clock_widget_right">Right</string>
|
||||
<string name="flexera_clock_widget_size">Size</string>
|
||||
<string name="flexera_clock_widget_small">Small</string>
|
||||
<string name="flexera_clock_widget_medium">Medium</string>
|
||||
<string name="flexera_clock_widget_large">Large</string>
|
||||
<string name="flexera_clock_widget_weight">Weight</string>
|
||||
<string name="flexera_clock_widget_thin">Thin</string>
|
||||
<string name="flexera_clock_widget_light">Light</string>
|
||||
<string name="flexera_clock_widget_regular">Regular</string>
|
||||
<string name="flexera_clock_widget_bold">Bold</string>
|
||||
<string name="flexera_clock_widget_weather">Weather</string>
|
||||
<string name="flexera_clock_widget_weather_off">Off. Look a place up to show its weather under the date.</string>
|
||||
<!-- %1$s is a place, such as "Berlin, State of Berlin, Germany" -->
|
||||
<string name="flexera_clock_widget_weather_on">Showing the weather in %1$s</string>
|
||||
<string name="flexera_clock_widget_weather_place">Town or city</string>
|
||||
<string name="flexera_clock_widget_weather_search">Search</string>
|
||||
<string name="flexera_clock_widget_weather_nothing">No place of that name was found</string>
|
||||
<string name="flexera_clock_widget_weather_failed">The weather service could not be reached. Check the connection and try again.</string>
|
||||
<string name="flexera_clock_widget_weather_remove">Turn weather off</string>
|
||||
<string name="flexera_clock_widget_weather_source">Weather data by Open-Meteo.com. The launcher asks it about the place you pick, about once an hour while the clock is in view, and sends nothing else.</string>
|
||||
<!-- The clock widget's weather line. %1$d is the temperature, %2$s a word for the sky, such as "Overcast" -->
|
||||
<string name="flexera_weather_line">%1$d° · %2$s</string>
|
||||
<string name="flexera_weather_clear">Clear</string>
|
||||
<string name="flexera_weather_mostly_clear">Mostly clear</string>
|
||||
<string name="flexera_weather_partly_cloudy">Partly cloudy</string>
|
||||
<string name="flexera_weather_overcast">Overcast</string>
|
||||
<string name="flexera_weather_fog">Fog</string>
|
||||
<string name="flexera_weather_drizzle">Drizzle</string>
|
||||
<string name="flexera_weather_rain">Rain</string>
|
||||
<string name="flexera_weather_freezing_rain">Freezing rain</string>
|
||||
<string name="flexera_weather_snow">Snow</string>
|
||||
<string name="flexera_weather_showers">Showers</string>
|
||||
<string name="flexera_weather_snow_showers">Snow showers</string>
|
||||
<string name="flexera_weather_thunderstorm">Thunderstorm</string>
|
||||
|
||||
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
|
||||
<!-- Its name in the list of widgets -->
|
||||
<string name="flexera_picture_widget">Pictures</string>
|
||||
|
||||
@@ -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,78 @@
|
||||
package app.flexera.drawer
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.lawnchair.LawnchairLauncher
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_ALL_APPS
|
||||
import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.model.data.ItemInfo
|
||||
import com.android.launcher3.popup.SystemShortcut
|
||||
import com.android.launcher3.util.ComponentKey
|
||||
import com.android.launcher3.views.Snackbar
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* "Hide app" in the menu a long press on an app opens in the app list.
|
||||
*
|
||||
* It puts the app on the hidden list Lawnchair already has: the one the app list and its search
|
||||
* leave out, kept under Settings > App drawer > Hidden apps, where an app is brought back. Before
|
||||
* this, hiding meant going through that settings page or through Customize. A message with Undo
|
||||
* follows, because the app is gone from the list the moment it is hidden.
|
||||
*/
|
||||
object HideApp {
|
||||
|
||||
/**
|
||||
* Offered for apps in the app list only: hiding does nothing to an icon on the home screen,
|
||||
* so offering it there would promise something else. Not offered while the home screen is
|
||||
* locked against changes, like Customize and Uninstall.
|
||||
*/
|
||||
@JvmField
|
||||
val SHORTCUT = SystemShortcut.Factory { launcher: LawnchairLauncher, itemInfo: ItemInfo, originalView: View ->
|
||||
val component = itemInfo.targetComponent
|
||||
when {
|
||||
itemInfo.container != CONTAINER_ALL_APPS -> null
|
||||
itemInfo.itemType != ITEM_TYPE_APPLICATION || component == null -> null
|
||||
PreferenceManager2.getInstance(launcher).lockHomeScreen.firstCached() -> null
|
||||
else -> Shortcut(launcher, itemInfo, originalView, ComponentKey(component, itemInfo.user))
|
||||
}
|
||||
}
|
||||
|
||||
private class Shortcut(
|
||||
launcher: LawnchairLauncher,
|
||||
itemInfo: ItemInfo,
|
||||
originalView: View,
|
||||
private val app: ComponentKey,
|
||||
) : SystemShortcut<LawnchairLauncher>(
|
||||
R.drawable.ic_flexera_hide,
|
||||
R.string.flexera_hide_app,
|
||||
launcher,
|
||||
itemInfo,
|
||||
originalView,
|
||||
) {
|
||||
override fun onClick(view: View) {
|
||||
val launcher = mTarget
|
||||
val name = mItemInfo.title?.toString().orEmpty()
|
||||
dismissTaskMenuView()
|
||||
setHidden(launcher, app, hidden = true)
|
||||
Snackbar.show(
|
||||
launcher,
|
||||
launcher.getString(R.string.flexera_app_hidden, name),
|
||||
R.string.undo,
|
||||
null,
|
||||
) { setHidden(launcher, app, hidden = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setHidden(launcher: LawnchairLauncher, app: ComponentKey, hidden: Boolean) {
|
||||
val hiddenApps = PreferenceManager2.getInstance(launcher).hiddenApps
|
||||
val key = app.toString()
|
||||
launcher.lifecycleScope.launch {
|
||||
val now = hiddenApps.get().first()
|
||||
hiddenApps.set(if (hidden) now + key else now - key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package app.flexera.home
|
||||
|
||||
import android.content.Context
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.Utilities
|
||||
|
||||
/**
|
||||
* The blur of the home screen behind an open folder: whether the owner wants it, and how strong
|
||||
* it is. The blur itself is the depth controller's work
|
||||
* ([com.android.quickstep.util.BaseDepthControllerImpl.folderBlur]), because that already blurs
|
||||
* the same views and the wallpaper for other reasons and would undo anything done past it. The
|
||||
* folder's opening and closing animation moves it
|
||||
* ([com.android.launcher3.folder.FolderSpringAnimatorSet]).
|
||||
*/
|
||||
object FolderBlur {
|
||||
|
||||
/**
|
||||
* The blur's radius behind a fully open folder, as a share of the largest radius the
|
||||
* launcher blurs with (30dp). Strong enough that the icons behind the folder are no longer
|
||||
* read, which is the point: the folder is what is in focus.
|
||||
*/
|
||||
const val STRENGTH = 0.8f
|
||||
|
||||
/** Blurring a view needs Android 12. */
|
||||
@JvmStatic
|
||||
fun isOn(context: Context): Boolean =
|
||||
Utilities.ATLEAST_S && PreferenceManager2.getInstance(context).folderBlur.firstCached()
|
||||
}
|
||||
@@ -200,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(
|
||||
@@ -341,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()
|
||||
@@ -353,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),
|
||||
)
|
||||
@@ -365,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,
|
||||
|
||||
@@ -149,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
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.Executors
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlin.math.roundToInt
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The weather line of the clock widget: the temperature and a word for the sky at a place the
|
||||
* owner has chosen.
|
||||
*
|
||||
* The data comes from Open-Meteo, a weather service that needs no account. It is asked only for
|
||||
* a place the owner picked by name in the clock's options, never for where the phone is, so the
|
||||
* launcher needs no location permission; and only while a clock with weather is in view, at most
|
||||
* every [FRESH_MS]. The place's coordinates, rounded to about a kilometre, are all that is sent.
|
||||
* The last answer is kept, so that the line is there at once and survives a spell offline.
|
||||
*/
|
||||
object ClockWeather {
|
||||
|
||||
/** A place found by name. [detail] tells places of one name apart: region and country. */
|
||||
data class Place(val name: String, val detail: String, val latitude: Double, val longitude: Double)
|
||||
|
||||
/** What the service said: degrees in the unit of the phone's country, and a WMO weather code. */
|
||||
data class Reading(val degrees: Int, val code: Int, val fahrenheit: Boolean, val readAt: Long)
|
||||
|
||||
/** Told on the main thread that a new reading has come in. */
|
||||
fun interface Listener {
|
||||
fun onWeatherChanged()
|
||||
}
|
||||
|
||||
private const val TAG = "ClockWeather"
|
||||
private const val FILE = "flexera_clock_weather"
|
||||
private const val FORECAST = "https://api.open-meteo.com/v1/forecast"
|
||||
private const val SEARCH = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
private const val TIMEOUT_MS = 10_000
|
||||
|
||||
/** A reading younger than this is not asked for again. */
|
||||
private const val FRESH_MS = 45 * 60 * 1000L
|
||||
|
||||
/** A reading older than this is no longer shown: stale weather is worse than none. */
|
||||
private const val SHOWN_MS = 3 * 60 * 60 * 1000L
|
||||
|
||||
/** The countries that count degrees in Fahrenheit. */
|
||||
private val fahrenheitCountries = setOf("US", "BS", "BZ", "KY", "PW", "FM", "MH", "LR")
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<Listener>()
|
||||
private val asking = HashSet<String>()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/** The places of this name, the likeliest first. Asks the service; throws when it cannot. */
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
fun search(name: String): List<Place> {
|
||||
val language = Locale.getDefault().language.ifEmpty { "en" }
|
||||
val answer = get("$SEARCH?name=${URLEncoder.encode(name, "UTF-8")}&count=6&language=$language&format=json")
|
||||
val results = JSONObject(answer).optJSONArray("results") ?: return emptyList()
|
||||
return (0 until results.length()).map { index ->
|
||||
val place = results.getJSONObject(index)
|
||||
val detail = listOf(place.optString("admin1"), place.optString("country"))
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(", ")
|
||||
Place(place.getString("name"), detail, place.getDouble("latitude"), place.getDouble("longitude"))
|
||||
}
|
||||
}
|
||||
|
||||
/** The last reading for a place, if there is one that is still worth showing. */
|
||||
fun reading(context: Context, place: Place): Reading? {
|
||||
val stored = prefs(context).getString(key(place), null)?.split('|') ?: return null
|
||||
if (stored.size != 4) return null
|
||||
val reading = Reading(
|
||||
degrees = stored[0].toIntOrNull() ?: return null,
|
||||
code = stored[1].toIntOrNull() ?: return null,
|
||||
fahrenheit = stored[2] == "F",
|
||||
readAt = stored[3].toLongOrNull() ?: return null,
|
||||
)
|
||||
val age = System.currentTimeMillis() - reading.readAt
|
||||
return if (age in 0..SHOWN_MS && reading.fahrenheit == usesFahrenheit()) reading else null
|
||||
}
|
||||
|
||||
/** Asks for a new reading in the background, unless the last one is fresh or one is on its way. */
|
||||
fun refreshIfStale(context: Context, place: Place) {
|
||||
val app = context.applicationContext
|
||||
val key = key(place)
|
||||
val last = reading(app, place)
|
||||
if (last != null && System.currentTimeMillis() - last.readAt < FRESH_MS) return
|
||||
synchronized(asking) {
|
||||
if (!asking.add(key)) return
|
||||
}
|
||||
Executors.THREAD_POOL_EXECUTOR.execute {
|
||||
try {
|
||||
val fahrenheit = usesFahrenheit()
|
||||
val answer = get(
|
||||
"$FORECAST?latitude=${coordinate(place.latitude)}&longitude=${coordinate(place.longitude)}" +
|
||||
"¤t=temperature_2m,weather_code" +
|
||||
"&temperature_unit=${if (fahrenheit) "fahrenheit" else "celsius"}",
|
||||
)
|
||||
val current = JSONObject(answer).getJSONObject("current")
|
||||
val degrees = current.getDouble("temperature_2m").roundToInt()
|
||||
val code = current.getInt("weather_code")
|
||||
prefs(app).edit()
|
||||
.putString(key, "$degrees|$code|${if (fahrenheit) "F" else "C"}|${System.currentTimeMillis()}")
|
||||
.apply()
|
||||
main.post { listeners.forEach { it.onWeatherChanged() } }
|
||||
} catch (e: Exception) {
|
||||
// Offline, or the service is down: the last reading stays until it is too old.
|
||||
Log.w(TAG, "No weather this time", e)
|
||||
} finally {
|
||||
synchronized(asking) { asking.remove(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The line as the widget shows it, such as "6° · Overcast". */
|
||||
fun line(context: Context, reading: Reading): String =
|
||||
context.getString(R.string.flexera_weather_line, reading.degrees, context.getString(word(reading.code)))
|
||||
|
||||
/** A word for a WMO weather code (the codes Open-Meteo uses). */
|
||||
private fun word(code: Int): Int = when (code) {
|
||||
0 -> R.string.flexera_weather_clear
|
||||
1 -> R.string.flexera_weather_mostly_clear
|
||||
2 -> R.string.flexera_weather_partly_cloudy
|
||||
3 -> R.string.flexera_weather_overcast
|
||||
45, 48 -> R.string.flexera_weather_fog
|
||||
51, 53, 55 -> R.string.flexera_weather_drizzle
|
||||
56, 57, 66, 67 -> R.string.flexera_weather_freezing_rain
|
||||
61, 63, 65 -> R.string.flexera_weather_rain
|
||||
71, 73, 75, 77 -> R.string.flexera_weather_snow
|
||||
80, 81, 82 -> R.string.flexera_weather_showers
|
||||
85, 86 -> R.string.flexera_weather_snow_showers
|
||||
95, 96, 99 -> R.string.flexera_weather_thunderstorm
|
||||
else -> R.string.flexera_weather_overcast
|
||||
}
|
||||
|
||||
private fun usesFahrenheit(): Boolean = Locale.getDefault().country in fahrenheitCountries
|
||||
|
||||
/** Two decimals, about a kilometre: all a forecast needs, and no more exact than that. */
|
||||
private fun coordinate(value: Double): String = String.format(Locale.ROOT, "%.2f", value)
|
||||
|
||||
private fun key(place: Place): String = "${coordinate(place.latitude)},${coordinate(place.longitude)}"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
private fun get(address: String): String {
|
||||
val connection = URL(address).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.connectTimeout = TIMEOUT_MS
|
||||
connection.readTimeout = TIMEOUT_MS
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IOException("The weather service answered HTTP ${connection.responseCode}")
|
||||
}
|
||||
return connection.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.flexera.widget.ClockWidgetStore.Align
|
||||
import app.flexera.widget.ClockWidgetStore.Size
|
||||
import app.flexera.widget.ClockWidgetStore.Weight
|
||||
import app.lawnchair.ui.theme.EdgeToEdge
|
||||
import app.lawnchair.ui.theme.LawnchairTheme
|
||||
import com.android.launcher3.R
|
||||
import java.util.TimeZone
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The clock widget's options: where its text stands, how large and how heavy its figures are,
|
||||
* and the place its weather line is for. Reached from the widget's edit button. It is a sheet
|
||||
* over the home screen, which stays in view, and every choice shows on the widget at once;
|
||||
* closing the sheet is all there is to finishing.
|
||||
*/
|
||||
class ClockWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val widget = intent?.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
if (widget == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// The widget is fine with or without a visit here.
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget))
|
||||
|
||||
setContent {
|
||||
LawnchairTheme {
|
||||
EdgeToEdge()
|
||||
var options by remember { mutableStateOf(ClockWidgetStore.options(this, widget)) }
|
||||
val change = { changed: ClockWidgetStore.Options ->
|
||||
options = changed
|
||||
ClockWidgetStore.set(this, widget, changed)
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { finish() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
// No dimming: the clock behind the sheet is what the choices are judged by.
|
||||
scrimColor = Color.Transparent,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.imePadding()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.flexera_clock_widget),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_alignment,
|
||||
choices = listOf(
|
||||
Align.LEFT to R.string.flexera_clock_widget_left,
|
||||
Align.CENTER to R.string.flexera_clock_widget_center,
|
||||
Align.RIGHT to R.string.flexera_clock_widget_right,
|
||||
),
|
||||
chosen = options.align,
|
||||
onChoose = { change(options.copy(align = it)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_size,
|
||||
choices = listOf(
|
||||
Size.SMALL to R.string.flexera_clock_widget_small,
|
||||
Size.MEDIUM to R.string.flexera_clock_widget_medium,
|
||||
Size.LARGE to R.string.flexera_clock_widget_large,
|
||||
),
|
||||
chosen = options.size,
|
||||
onChoose = { change(options.copy(size = it)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_weight,
|
||||
choices = listOf(
|
||||
Weight.THIN to R.string.flexera_clock_widget_thin,
|
||||
Weight.LIGHT to R.string.flexera_clock_widget_light,
|
||||
Weight.REGULAR to R.string.flexera_clock_widget_regular,
|
||||
Weight.BOLD to R.string.flexera_clock_widget_bold,
|
||||
),
|
||||
chosen = options.weight,
|
||||
onChoose = { change(options.copy(weight = it)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
WeatherChoice(
|
||||
place = options.weather,
|
||||
onChoose = { change(options.copy(weather = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Heading(@StringRes text: Int) {
|
||||
Text(
|
||||
text = stringResource(id = text),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
/** A heading with one row of choices under it, of which one is taken. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun <T> Choice(
|
||||
@StringRes heading: Int,
|
||||
choices: List<Pair<T, Int>>,
|
||||
chosen: T,
|
||||
onChoose: (T) -> Unit,
|
||||
) {
|
||||
Heading(heading)
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
choices.forEachIndexed { index, (value, label) ->
|
||||
SegmentedButton(
|
||||
selected = value == chosen,
|
||||
onClick = { onChoose(value) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
|
||||
) {
|
||||
Text(text = stringResource(id = label), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Search { IDLE, RUNNING, NOTHING, FAILED }
|
||||
|
||||
/**
|
||||
* The weather line: off until the owner looks a place up by name and picks it from what the
|
||||
* weather service finds. The field starts with the city of the phone's time zone as a guess.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeatherChoice(place: ClockWeather.Place?, onChoose: (ClockWeather.Place?) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by rememberSaveable { mutableStateOf(place?.name ?: timeZoneCity()) }
|
||||
var found by remember { mutableStateOf(emptyList<ClockWeather.Place>()) }
|
||||
var search by remember { mutableStateOf(Search.IDLE) }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focus = LocalFocusManager.current
|
||||
val look = {
|
||||
val wanted = name.trim()
|
||||
if (wanted.isNotEmpty() && search != Search.RUNNING) {
|
||||
// The places found are listed under the field, where the keyboard would cover them.
|
||||
keyboard?.hide()
|
||||
focus.clearFocus()
|
||||
search = Search.RUNNING
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { runCatching { ClockWeather.search(wanted) } }
|
||||
found = result.getOrDefault(emptyList())
|
||||
search = when {
|
||||
result.isFailure -> Search.FAILED
|
||||
found.isEmpty() -> Search.NOTHING
|
||||
else -> Search.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Heading(R.string.flexera_clock_widget_weather)
|
||||
Text(
|
||||
text = if (place == null) {
|
||||
stringResource(id = R.string.flexera_clock_widget_weather_off)
|
||||
} else {
|
||||
stringResource(id = R.string.flexera_clock_widget_weather_on, placeLabel(place))
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(text = stringResource(id = R.string.flexera_clock_widget_weather_place)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { look() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(onClick = { look() }) {
|
||||
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_search))
|
||||
}
|
||||
}
|
||||
when (search) {
|
||||
Search.RUNNING -> LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
Search.NOTHING -> Note(R.string.flexera_clock_widget_weather_nothing)
|
||||
|
||||
Search.FAILED -> Note(R.string.flexera_clock_widget_weather_failed)
|
||||
|
||||
Search.IDLE -> Unit
|
||||
}
|
||||
found.forEach { candidate ->
|
||||
Text(
|
||||
text = placeLabel(candidate),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onChoose(candidate)
|
||||
name = candidate.name
|
||||
found = emptyList()
|
||||
}
|
||||
.padding(vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
if (place != null) {
|
||||
TextButton(onClick = { onChoose(null) }) {
|
||||
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_remove))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.flexera_clock_widget_weather_source),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Note(@StringRes text: Int) {
|
||||
Text(
|
||||
text = stringResource(id = text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun placeLabel(place: ClockWeather.Place): String =
|
||||
if (place.detail.isEmpty()) place.name else "${place.name}, ${place.detail}"
|
||||
|
||||
/** The city a time zone is named after ("Europe/Zagreb" gives "Zagreb"); empty for zones without one. */
|
||||
private fun timeZoneCity(): String {
|
||||
val id = TimeZone.getDefault().id
|
||||
return if ('/' in id) id.substringAfterLast('/').replace('_', ' ') else ""
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import com.android.launcher3.BuildConfig
|
||||
|
||||
/**
|
||||
* The launcher's own clock widget: the time, large, with the date under it. Android knows it as
|
||||
* an ordinary app widget; the launcher draws it itself with a [ClockWidgetView]. It needs no
|
||||
* setting up; what the owner may choose for it is kept in [ClockWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class ClockWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
appWidgetIds.forEach { ClockWidgetStore.delete(context, it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, ClockWidgetProvider::class.java.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
/**
|
||||
* What the owner has chosen for each clock widget on the home screen: where its text stands, how
|
||||
* large and how heavy its figures are, and the place its weather line is for, if it has one.
|
||||
* Kept per widget, so that two clocks can differ.
|
||||
*/
|
||||
object ClockWidgetStore {
|
||||
|
||||
/** Which side of the widget the time and the date stand on. */
|
||||
enum class Align(val key: String) { LEFT("left"), CENTER("center"), RIGHT("right") }
|
||||
|
||||
/** How large the figures are, measured against the icons (see ClockWidgetView). */
|
||||
enum class Size(val key: String) { SMALL("small"), MEDIUM("medium"), LARGE("large") }
|
||||
|
||||
/** How heavy the figures are drawn; [value] is the font weight, from 100 to 900. */
|
||||
enum class Weight(val key: String, val value: Int) {
|
||||
THIN("thin", 100),
|
||||
LIGHT("light", 300),
|
||||
REGULAR("regular", 400),
|
||||
BOLD("bold", 700),
|
||||
}
|
||||
|
||||
data class Options(
|
||||
val align: Align = Align.LEFT,
|
||||
val size: Size = Size.MEDIUM,
|
||||
val weight: Weight = Weight.LIGHT,
|
||||
/** The place of the weather line; null for a clock without weather, which is how it starts. */
|
||||
val weather: ClockWeather.Place? = null,
|
||||
)
|
||||
|
||||
/** Told, on the thread that made the change, that a widget's options are different now. */
|
||||
fun interface Listener {
|
||||
fun onClockOptionsChanged(appWidgetId: Int)
|
||||
}
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<Listener>()
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun options(context: Context, appWidgetId: Int): Options {
|
||||
val prefs = prefs(context)
|
||||
val align = prefs.getString(ALIGN + appWidgetId, null)
|
||||
val size = prefs.getString(SIZE + appWidgetId, null)
|
||||
val weight = prefs.getString(WEIGHT + appWidgetId, null)
|
||||
val place = prefs.getString(PLACE + appWidgetId, null)
|
||||
return Options(
|
||||
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
|
||||
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
|
||||
weight = Weight.entries.firstOrNull { it.key == weight } ?: Weight.LIGHT,
|
||||
weather = place?.let {
|
||||
ClockWeather.Place(
|
||||
name = it,
|
||||
detail = prefs.getString(PLACE_DETAIL + appWidgetId, "").orEmpty(),
|
||||
latitude = prefs.getFloat(LATITUDE + appWidgetId, 0f).toDouble(),
|
||||
longitude = prefs.getFloat(LONGITUDE + appWidgetId, 0f).toDouble(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun set(context: Context, appWidgetId: Int, options: Options) {
|
||||
val edit = prefs(context).edit()
|
||||
.putString(ALIGN + appWidgetId, options.align.key)
|
||||
.putString(SIZE + appWidgetId, options.size.key)
|
||||
.putString(WEIGHT + appWidgetId, options.weight.key)
|
||||
val place = options.weather
|
||||
if (place == null) {
|
||||
edit.remove(PLACE + appWidgetId).remove(PLACE_DETAIL + appWidgetId)
|
||||
.remove(LATITUDE + appWidgetId).remove(LONGITUDE + appWidgetId)
|
||||
} else {
|
||||
edit.putString(PLACE + appWidgetId, place.name)
|
||||
.putString(PLACE_DETAIL + appWidgetId, place.detail)
|
||||
.putFloat(LATITUDE + appWidgetId, place.latitude.toFloat())
|
||||
.putFloat(LONGITUDE + appWidgetId, place.longitude.toFloat())
|
||||
}
|
||||
edit.apply()
|
||||
listeners.forEach { it.onClockOptionsChanged(appWidgetId) }
|
||||
}
|
||||
|
||||
/** Forgets the options of a widget that left the home screen. */
|
||||
fun delete(context: Context, appWidgetId: Int) {
|
||||
val edit = prefs(context).edit()
|
||||
for (key in listOf(ALIGN, SIZE, WEIGHT, PLACE, PLACE_DETAIL, LATITUDE, LONGITUDE)) {
|
||||
edit.remove(key + appWidgetId)
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
private const val FILE = "flexera_clock_widget"
|
||||
private const val ALIGN = "align_"
|
||||
private const val SIZE = "size_"
|
||||
private const val WEIGHT = "weight_"
|
||||
private const val PLACE = "place_"
|
||||
private const val PLACE_DETAIL = "place_detail_"
|
||||
private const val LATITUDE = "latitude_"
|
||||
private const val LONGITUDE = "longitude_"
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.provider.AlarmClock
|
||||
import android.text.TextPaint
|
||||
import android.text.format.DateFormat
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.flexera.widget.ClockWidgetStore.Align
|
||||
import app.flexera.widget.ClockWidgetStore.Size
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.Themes
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* What the clock widget ([ClockWidgetProvider]) looks like: the time in light figures and the
|
||||
* date under it, written straight onto the wallpaper in the colour of the icon labels.
|
||||
*
|
||||
* Like the picture widget it keeps to the box of the icons in its cells ([IconFrame]). The
|
||||
* owner chooses which side of that box the text stands on, or its middle, and how large the
|
||||
* figures are ([ClockWidgetStore], set from the widget's edit button). The sizes are measured
|
||||
* against the icons, so that the clock keeps its proportion to them on any grid: small figures
|
||||
* are about half an icon tall, medium ones most of an icon, large ones taller than an icon. The
|
||||
* widget's own height and width are the limit in every case. Up and down the text stands in the
|
||||
* middle of the widget's own cells, which is what the outline shows while the widget is edited.
|
||||
*
|
||||
* A clock for which the owner has picked a place has a third line, the weather there
|
||||
* ([ClockWeather]).
|
||||
*
|
||||
* A tap opens the phone's clock app.
|
||||
*/
|
||||
class ClockWidgetView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs),
|
||||
ClockWidgetStore.Listener,
|
||||
ClockWeather.Listener {
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
/** True while this is only a sample of the widget, in the list of widgets. */
|
||||
private var sample = true
|
||||
private var options = ClockWidgetStore.Options()
|
||||
|
||||
private val frame = RectF()
|
||||
|
||||
/** What the figures are measured against: the height of what is seen of an icon. */
|
||||
private var iconHeight = 0f
|
||||
private val ink = Rect()
|
||||
private val now = Calendar.getInstance()
|
||||
private var time = ""
|
||||
private var date = ""
|
||||
|
||||
/** The weather line; empty for a clock without weather or while none is known. */
|
||||
private var weather = ""
|
||||
|
||||
private val timePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
// Figures of one width, so that the time does not shift sideways from minute to minute.
|
||||
fontFeatureSettings = "tnum"
|
||||
}
|
||||
private val datePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = weight(DATE_WEIGHT)
|
||||
}
|
||||
private val weatherPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = weight(WEATHER_WEIGHT)
|
||||
}
|
||||
|
||||
// Where the lines go; worked out in arrange().
|
||||
private var timeX = 0f
|
||||
private var timeBaseline = 0f
|
||||
private var dateX = 0f
|
||||
private var dateBaseline = 0f
|
||||
private var weatherX = 0f
|
||||
private var weatherBaseline = 0f
|
||||
|
||||
private var listening = false
|
||||
private val clock = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) now.timeZone = TimeZone.getDefault()
|
||||
tell()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
// Says to Launcher3 that this widget shapes itself (see RoundedCornerEnforcement).
|
||||
id = android.R.id.background
|
||||
clipToOutline = true
|
||||
colour()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the home screen the text has the colour of the icon labels, which follows the
|
||||
* wallpaper. The list of widgets is a sheet of its own, so a sample there takes the sheet's
|
||||
* text colour.
|
||||
*/
|
||||
private fun colour() {
|
||||
val color = if (sample) {
|
||||
Themes.getAttrColor(context, android.R.attr.textColorPrimary)
|
||||
} else {
|
||||
Themes.getAttrColor(context, R.attr.workspaceTextColor)
|
||||
}
|
||||
val paints = listOf(timePaint, datePaint, weatherPaint)
|
||||
paints.forEach { it.color = color }
|
||||
// Light text on a wallpaper gets the soft shadow the icon labels have.
|
||||
if (!sample && Color.luminance(color) > 0.5f) {
|
||||
val density = resources.displayMetrics.density
|
||||
paints.forEach {
|
||||
it.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
|
||||
}
|
||||
} else {
|
||||
paints.forEach { it.clearShadowLayer() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes the owner's choices for this widget: how the figures are drawn, and the weather. */
|
||||
private fun takeOptions() {
|
||||
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
|
||||
timePaint.typeface = weight(options.weight.value)
|
||||
readWeather()
|
||||
}
|
||||
|
||||
/** Shows the last known weather for the chosen place and asks for newer if it is due. */
|
||||
private fun readWeather() {
|
||||
val place = options.weather
|
||||
weather = if (place == null) {
|
||||
""
|
||||
} else {
|
||||
if (isAttachedToWindow && !sample) ClockWeather.refreshIfStale(context, place)
|
||||
ClockWeather.reading(context, place)?.let { ClockWeather.line(context, it) }.orEmpty()
|
||||
}
|
||||
describe()
|
||||
}
|
||||
|
||||
override fun onWeatherChanged() {
|
||||
val before = weather
|
||||
readWeather()
|
||||
if (weather != before) {
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the view which widget it shows. Ids that Android never hands out mean a sample. */
|
||||
fun bind(appWidgetId: Int) {
|
||||
this.appWidgetId = appWidgetId
|
||||
sample = appWidgetId <= 0
|
||||
takeOptions()
|
||||
colour()
|
||||
if (sample) {
|
||||
// In the list of widgets a tap belongs to the list.
|
||||
setOnClickListener(null)
|
||||
isClickable = false
|
||||
} else {
|
||||
setOnClickListener { openClock() }
|
||||
}
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onClockOptionsChanged(appWidgetId: Int) {
|
||||
if (sample || appWidgetId != this.appWidgetId) return
|
||||
takeOptions()
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
ClockWidgetStore.addListener(this)
|
||||
ClockWeather.addListener(this)
|
||||
if (!listening) {
|
||||
val changes = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_TIME_TICK)
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
addAction(Intent.ACTION_LOCALE_CHANGED)
|
||||
}
|
||||
ContextCompat.registerReceiver(context, clock, changes, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
listening = true
|
||||
}
|
||||
now.timeZone = TimeZone.getDefault()
|
||||
takeOptions()
|
||||
tell()
|
||||
arrange()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
ClockWidgetStore.removeListener(this)
|
||||
ClockWeather.removeListener(this)
|
||||
if (listening) {
|
||||
context.unregisterReceiver(clock)
|
||||
listening = false
|
||||
}
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onVisibilityAggregated(isVisible: Boolean) {
|
||||
super.onVisibilityAggregated(isVisible)
|
||||
// Android does not announce the minutes to an app in the background; catch up, with the
|
||||
// time and with the weather.
|
||||
if (isVisible) {
|
||||
tell()
|
||||
onWeatherChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
val aligned = IconFrame.of(this)
|
||||
if (aligned != null) {
|
||||
frame.set(aligned.left, aligned.top, aligned.right, aligned.bottom)
|
||||
iconHeight = aligned.visibleIconSize
|
||||
} else {
|
||||
// In the list of widgets: the whole view with a little air, and a made-up icon.
|
||||
val margin = min(width, height) * LOOSE_MARGIN_SHARE
|
||||
frame.set(margin, margin, width - margin, height - margin)
|
||||
iconHeight = frame.height() * LOOSE_ICON_SHARE
|
||||
}
|
||||
arrange()
|
||||
}
|
||||
|
||||
/** Reads the clock and shows what it says. */
|
||||
private fun tell() {
|
||||
now.timeInMillis = System.currentTimeMillis()
|
||||
val locale = Locale.getDefault()
|
||||
// The phone's own choice of 12 or 24 hours, without "AM" and "PM": the date line and
|
||||
// the daylight say which half of the day it is.
|
||||
val hours = if (DateFormat.is24HourFormat(context)) "Hm" else "hm"
|
||||
val timePattern = DateFormat.getBestDateTimePattern(locale, hours).replace("a", "").trim()
|
||||
val datePattern = DateFormat.getBestDateTimePattern(locale, "EEEEdMMMM")
|
||||
val newTime = DateFormat.format(timePattern, now).toString()
|
||||
val newDate = DateFormat.format(datePattern, now).toString()
|
||||
if (newTime == time && newDate == date) return
|
||||
time = newTime
|
||||
date = newDate
|
||||
describe()
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** What a screen reader says for the widget: everything it shows. */
|
||||
private fun describe() {
|
||||
contentDescription = listOf(time, date, weather).filter { it.isNotEmpty() }.joinToString(", ")
|
||||
}
|
||||
|
||||
/** Sizes the two lines for the frame and the owner's choices, and works out where they go. */
|
||||
private fun arrange() {
|
||||
val width = frame.width()
|
||||
if (width <= 0f || frame.height() <= 0f || time.isEmpty()) return
|
||||
val scaled = resources.displayMetrics.scaledDensity
|
||||
// Up and down, the room is the widget's own cells; in the list of widgets, the frame.
|
||||
val roomTop = if (sample) frame.top else 0f
|
||||
val roomHeight = if (sample) frame.height() else height.toFloat()
|
||||
|
||||
// How tall the figures would like to be.
|
||||
val wanted = iconHeight * when (options.size) {
|
||||
Size.SMALL -> SMALL_IN_ICONS
|
||||
Size.MEDIUM -> MEDIUM_IN_ICONS
|
||||
Size.LARGE -> LARGE_IN_ICONS
|
||||
}
|
||||
|
||||
// The lines under the figures go with them in size, within the sizes that read well, and
|
||||
// are never wider than the frame.
|
||||
var smallSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
|
||||
datePaint.textSize = smallSize
|
||||
weatherPaint.textSize = smallSize
|
||||
val widest = max(datePaint.measureText(date), weatherPaint.measureText(weather))
|
||||
if (widest > width) {
|
||||
smallSize *= width / widest
|
||||
datePaint.textSize = smallSize
|
||||
weatherPaint.textSize = smallSize
|
||||
}
|
||||
// What is seen of a line of words is its capitals' height; the room a font keeps above
|
||||
// and below them would push the text off the middle.
|
||||
val smallHeight = smallSize * CAPITALS_IN_TEXT_SIZE
|
||||
val gap = smallSize * GAP_IN_DATE_SIZES
|
||||
val lineGap = smallSize * LINE_GAP_IN_DATE_SIZES
|
||||
val under = gap + smallHeight + if (weather.isEmpty()) 0f else lineGap + smallHeight
|
||||
|
||||
// The figures: as tall as wanted, unless the room above the other lines or the frame's
|
||||
// width is less. Measured on the figures themselves, not on the line they stand in.
|
||||
timePaint.textSize = MEASURE_SIZE
|
||||
timePaint.getTextBounds(time, 0, time.length, ink)
|
||||
val inkHeightAtMeasure = max(1, ink.height()).toFloat()
|
||||
val inkWidthAtMeasure = max(1, ink.width()).toFloat()
|
||||
val figures = min(wanted, min(roomHeight - under, width * inkHeightAtMeasure / inkWidthAtMeasure))
|
||||
timePaint.textSize = max(1f, MEASURE_SIZE * figures / inkHeightAtMeasure)
|
||||
timePaint.getTextBounds(time, 0, time.length, ink)
|
||||
|
||||
// All lines together, in the middle of the room.
|
||||
val block = ink.height() + under
|
||||
val top = roomTop + (roomHeight - block) / 2f
|
||||
timeBaseline = top - ink.top
|
||||
dateBaseline = top + ink.height() + gap + smallHeight
|
||||
weatherBaseline = dateBaseline + lineGap + smallHeight
|
||||
|
||||
// Sideways it is the figures' own edges that meet the frame's, not the space the font
|
||||
// leaves beside them.
|
||||
val dateLength = datePaint.measureText(date)
|
||||
val weatherLength = weatherPaint.measureText(weather)
|
||||
when (options.align) {
|
||||
Align.LEFT -> {
|
||||
timeX = frame.left - ink.left
|
||||
dateX = frame.left
|
||||
weatherX = frame.left
|
||||
}
|
||||
|
||||
Align.CENTER -> {
|
||||
timeX = frame.centerX() - ink.exactCenterX()
|
||||
dateX = frame.centerX() - dateLength / 2f
|
||||
weatherX = frame.centerX() - weatherLength / 2f
|
||||
}
|
||||
|
||||
Align.RIGHT -> {
|
||||
timeX = frame.right - ink.right
|
||||
dateX = frame.right - dateLength
|
||||
weatherX = frame.right - weatherLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (frame.isEmpty || time.isEmpty()) return
|
||||
canvas.drawText(time, timeX, timeBaseline, timePaint)
|
||||
canvas.drawText(date, dateX, dateBaseline, datePaint)
|
||||
if (weather.isNotEmpty()) canvas.drawText(weather, weatherX, weatherBaseline, weatherPaint)
|
||||
}
|
||||
|
||||
private fun openClock() {
|
||||
val alarms = Intent(AlarmClock.ACTION_SHOW_ALARMS)
|
||||
try {
|
||||
// The clock app's own front door; its alarm list needs a permission of its own.
|
||||
val clockApp = context.packageManager.resolveActivity(alarms, 0)?.activityInfo?.packageName
|
||||
val intent = clockApp?.let(context.packageManager::getLaunchIntentForPackage) ?: return
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "The clock app could not be opened", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** The phone's own font at a weight from 100 (thin) to 900. Android 8 only knows a few. */
|
||||
private fun weight(weight: Int): Typeface =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Typeface.create(Typeface.DEFAULT, weight, false)
|
||||
} else {
|
||||
val family = when {
|
||||
weight <= 100 -> "sans-serif-thin"
|
||||
weight <= 300 -> "sans-serif-light"
|
||||
weight <= 400 -> "sans-serif"
|
||||
weight <= 500 -> "sans-serif-medium"
|
||||
else -> "sans-serif-black"
|
||||
}
|
||||
Typeface.create(family, Typeface.NORMAL)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ClockWidgetView"
|
||||
|
||||
const val DATE_WEIGHT = 500
|
||||
const val WEATHER_WEIGHT = 400
|
||||
|
||||
/**
|
||||
* How tall the figures are for each size, in icon heights. (The first version filled
|
||||
* the widget, about 1.7 icons on a widget two rows high, which the owner found huge.)
|
||||
*/
|
||||
const val SMALL_IN_ICONS = 0.5f
|
||||
const val MEDIUM_IN_ICONS = 0.8f
|
||||
const val LARGE_IN_ICONS = 1.25f
|
||||
|
||||
/** The date's text size as a share of the figures' height, and its limits. */
|
||||
const val DATE_IN_FIGURES = 0.26f
|
||||
const val DATE_MIN_SP = 12f
|
||||
const val DATE_MAX_SP = 18f
|
||||
|
||||
/** How tall a font's capitals are, as a share of its text size. */
|
||||
const val CAPITALS_IN_TEXT_SIZE = 0.72f
|
||||
|
||||
/** The space between the figures and the date, and between the lines under them, in date text sizes. */
|
||||
const val GAP_IN_DATE_SIZES = 0.8f
|
||||
const val LINE_GAP_IN_DATE_SIZES = 0.65f
|
||||
|
||||
const val MEASURE_SIZE = 200f
|
||||
const val LOOSE_MARGIN_SHARE = 0.12f
|
||||
const val LOOSE_ICON_SHARE = 0.45f
|
||||
|
||||
const val SHADOW_RADIUS_DP = 3f
|
||||
const val SHADOW_DROP_DP = 1f
|
||||
const val SHADOW_COLOR = 0x59000000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,7 @@ package app.flexera.widget
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import com.android.launcher3.BuildConfig
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
|
||||
/**
|
||||
* The launcher's own picture widget: one picture, or several that take turns.
|
||||
@@ -14,7 +11,7 @@ import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
* 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].
|
||||
* pictures live in [PictureWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class PictureWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
@@ -32,18 +29,5 @@ class PictureWidgetProvider : AppWidgetProvider() {
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, PictureWidgetProvider::class.java.name)
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget
|
||||
* the same ([standard]); the picture widget takes none, because it places its picture
|
||||
* against the icons of its cells by itself.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (info.providerName == componentName) noInset else standard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
@@ -23,7 +22,6 @@ import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import app.lawnchair.icons.shape.CornerShapeCompat
|
||||
@@ -31,13 +29,7 @@ import app.lawnchair.icons.shape.IconShape
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.ShortcutAndWidgetContainer
|
||||
import com.android.launcher3.celllayout.CellLayoutLayoutParams
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.util.Executors
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -365,50 +357,14 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
var corner = min(width, height) * LOOSE_CORNER_SHARE
|
||||
var iconSize = min(width, height).toFloat()
|
||||
|
||||
val host = parent as? View
|
||||
val lp = host?.layoutParams as? CellLayoutLayoutParams
|
||||
val container = host?.parent as? ShortcutAndWidgetContainer
|
||||
val profile = activityContext()?.deviceProfile
|
||||
if (host != null && lp != null && container != null && profile != null &&
|
||||
lp.cellHSpan > 0 && lp.cellVSpan > 0
|
||||
) {
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = PictureWidgetProvider.cellInset(host, profile.widgetPadding)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
|
||||
val gap = profile.cellLayoutBorderSpacePx
|
||||
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
|
||||
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
|
||||
|
||||
// Where an icon sits in its cell: centred sideways and a set distance below the
|
||||
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val iconLeft = (cellWidth - icon) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
|
||||
}
|
||||
// What is seen of an icon is a little smaller than the square it is drawn in; the
|
||||
// rest is room for its shadow.
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - getLeft()
|
||||
val r = spanLeft + spanWidth - iconLeft - air - getLeft()
|
||||
val t = spanTop + iconTop + air - getTop()
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - getTop()
|
||||
if (r - l >= 1f && b - t >= 1f) {
|
||||
left = l
|
||||
top = t
|
||||
right = r
|
||||
bottom = b
|
||||
corner = visible / 2f
|
||||
iconSize = icon
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -537,15 +493,6 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
|
||||
private fun currentIconShape(): IconShape = PreferenceManager2.getInstance(context).iconShape.firstCached()
|
||||
|
||||
private fun activityContext(): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PictureWidgetView"
|
||||
|
||||
@@ -580,38 +527,5 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
const val HINT_MAX_LINES = 2
|
||||
const val HINT_MAX_HEIGHT_SHARE = 0.4f
|
||||
const val HINT_CENTRE_SHARE = 0.36f
|
||||
|
||||
/**
|
||||
* Puts a [PictureWidgetView] into a widget host view when the widget is the picture
|
||||
* widget, and returns it; returns null for every other widget. Launcher3's host view
|
||||
* calls this in place of asking Android for the widget's views.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun attachIfOwn(
|
||||
host: LauncherAppWidgetHostView,
|
||||
appWidgetId: Int,
|
||||
info: AppWidgetProviderInfo?,
|
||||
): View? {
|
||||
if (info == null || info.provider != PictureWidgetProvider.componentName) return null
|
||||
// A widget that still waits to be set up draws its own "tap to set up".
|
||||
if (host is PendingAppWidgetHostView) return null
|
||||
var view: PictureWidgetView? = null
|
||||
for (i in host.childCount - 1 downTo 0) {
|
||||
val child = host.getChildAt(i)
|
||||
if (child is PictureWidgetView && view == null) view = child else host.removeViewAt(i)
|
||||
}
|
||||
if (view == null) {
|
||||
view = PictureWidgetView(host.context)
|
||||
host.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.bind(appWidgetId)
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
# build.gradle reads this and derives the Android version code from it
|
||||
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
|
||||
# flexera/release.ps1 raises it for every release.
|
||||
versionName=0.9.0
|
||||
versionName=0.13.0
|
||||
@@ -283,7 +283,8 @@ class LawnchairLauncher : QuickstepLauncher() {
|
||||
super.getSupportedShortcuts(container),
|
||||
Stream.concat(
|
||||
Stream.of(LawnchairShortcut.UNINSTALL, LawnchairShortcut.CUSTOMIZE, LawnchairShortcut.OPEN_IN_STORE),
|
||||
Stream.of(LawnchairShortcut.PAUSE_APPS),
|
||||
// Flexera Launcher: "Hide app", which its factory offers in the app list only.
|
||||
Stream.of(LawnchairShortcut.PAUSE_APPS, app.flexera.drawer.HideApp.SHORTCUT),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -886,6 +886,13 @@ class PreferenceManager2 @Inject constructor(
|
||||
defaultValue = GestureHandlerConfig.NoOp,
|
||||
)
|
||||
|
||||
// Flexera Launcher: whether the home screen behind an open folder is blurred
|
||||
// (app.flexera.home.FolderBlur). Read when a folder opens, so nothing has to be reloaded.
|
||||
val folderBlur = preference(
|
||||
key = booleanPreferencesKey(name = "flexera_folder_blur"),
|
||||
defaultValue = true,
|
||||
)
|
||||
|
||||
val autoUpdaterNightly = preference(
|
||||
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
|
||||
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
|
||||
@@ -912,10 +919,16 @@ class PreferenceManager2 @Inject constructor(
|
||||
.onEach { cachedPreferences = it }
|
||||
.launchIn(scope)
|
||||
|
||||
// Flexera Launcher: in the five collectors below, distinctUntilChanged comes before
|
||||
// drop(1). Upstream has them the other way round, which lets the first emission after
|
||||
// the dropped one through whatever its value: the first write of any preference after
|
||||
// the launcher started then reloaded the model, rebuilt the grid three times and
|
||||
// recreated the launcher. Hiding an app from the app list is such a write, made while
|
||||
// the owner looks at the launcher.
|
||||
initializeIconShape(iconShape.firstCached(this))
|
||||
iconShape.get()
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.onEach { shape ->
|
||||
initializeIconShape(shape)
|
||||
L3ThemeManager.INSTANCE.get(context)
|
||||
@@ -924,14 +937,14 @@ class PreferenceManager2 @Inject constructor(
|
||||
.launchIn(scope)
|
||||
|
||||
hotseatMode.get()
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.onEach { reloadHelper.restart() }
|
||||
.launchIn(scope)
|
||||
|
||||
isHotseatEnabled.get()
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.onEach {
|
||||
reloadHelper.recreate()
|
||||
reloadHelper.reloadGrid()
|
||||
@@ -939,14 +952,14 @@ class PreferenceManager2 @Inject constructor(
|
||||
.launchIn(scope)
|
||||
|
||||
hotseatQsbProvider.get()
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.onEach { reloadHelper.recreate() }
|
||||
.launchIn(scope)
|
||||
|
||||
enableLabelInDock.get()
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.onEach { reloadHelper.reloadGrid() }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
|
||||
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
|
||||
import app.lawnchair.ui.preferences.navigation.GeneralIconShape
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.Utilities
|
||||
|
||||
@Composable
|
||||
fun FolderPreferences(
|
||||
@@ -75,6 +76,14 @@ fun FolderPreferences(
|
||||
valueRange = 0F..1F,
|
||||
showAsPercentage = true,
|
||||
)
|
||||
// Flexera Launcher: blurring a view needs Android 12.
|
||||
if (Utilities.ATLEAST_S) {
|
||||
SwitchPreference(
|
||||
adapter = prefs2.folderBlur.getAdapter(),
|
||||
label = stringResource(id = R.string.flexera_folder_blur),
|
||||
description = stringResource(id = R.string.flexera_folder_blur_description),
|
||||
)
|
||||
}
|
||||
}
|
||||
PreferenceGroup(heading = stringResource(id = R.string.grid)) {
|
||||
SliderPreference(
|
||||
|
||||
@@ -32,6 +32,7 @@ import android.view.ViewTreeObserver;
|
||||
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.android.launcher3.AbstractFloatingView;
|
||||
import com.android.launcher3.BaseActivity;
|
||||
import com.android.launcher3.LauncherState;
|
||||
import com.android.launcher3.Utilities;
|
||||
@@ -220,6 +221,12 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
|
||||
* Clears a stuck workspace icon blur after resume when settled on home.
|
||||
*/
|
||||
public void clearStuckBlurOnResumeIfHome() {
|
||||
// Flexera Launcher: a folder blur with no folder on the screen, open or still closing,
|
||||
// is a stuck one too. It should not come about; this makes sure it could not stay.
|
||||
if (folderBlur.getValue() > 0f && AbstractFloatingView.getAnyView(
|
||||
mLauncher, AbstractFloatingView.TYPE_FOLDER) == null) {
|
||||
folderBlur.setValue(0f);
|
||||
}
|
||||
if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
|
||||
&& !mLauncher.getStateManager().isInTransition()) {
|
||||
clearWorkspaceRenderEffects();
|
||||
@@ -227,10 +234,18 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isDepthEffectEnabled() {
|
||||
return mEnableDepth;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void applyDepthAndBlur() {
|
||||
try {
|
||||
if (LawnchairQuickstepCompat.ATLEAST_R && mEnableDepth) {
|
||||
// Flexera Launcher: the blur behind an open folder is a setting of its own, and
|
||||
// also comes where the wallpaper's zoom is switched off.
|
||||
if (LawnchairQuickstepCompat.ATLEAST_R
|
||||
&& (mEnableDepth || folderBlurNeedsApplying())) {
|
||||
ensureDependencies();
|
||||
super.applyDepthAndBlur();
|
||||
} else if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
|
||||
|
||||
@@ -90,6 +90,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
public final MultiProperty widgetDepth;
|
||||
/** Property to set just the wallpaper zoom for folder expansion. */
|
||||
public final MultiProperty folderZoom;
|
||||
/**
|
||||
* Flexera Launcher: property to set the blur behind an open folder, from 0 to 1. Unlike the
|
||||
* depth it comes with no zoom, and it reaches the icons as well as the wallpaper.
|
||||
*/
|
||||
public final MultiProperty folderBlur;
|
||||
|
||||
/**
|
||||
* Blur radius when completely zoomed out, in pixels.
|
||||
@@ -106,6 +111,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
private float mDepth;
|
||||
// Used just for animating wallpaper zoom without affecting blur.
|
||||
private final AnimatedFloat mWallpaperZoomOnly = new AnimatedFloat(this::applyDepthAndBlur);
|
||||
// Flexera Launcher: the blur behind an open folder, see folderBlur.
|
||||
private final AnimatedFloat mFolderBlur = new AnimatedFloat(this::applyDepthAndBlur);
|
||||
// Whether a folder blur is on the launcher's surface, and so has to come off again.
|
||||
private boolean mFolderBlurApplied;
|
||||
|
||||
protected SurfaceControl mBaseSurface;
|
||||
|
||||
@@ -164,6 +173,9 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
} else {
|
||||
folderZoom = null;
|
||||
}
|
||||
// Flexera Launcher
|
||||
folderBlur = new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 1, Float::max)
|
||||
.get(0);
|
||||
|
||||
// mEarlyWakeupInfo.token = new Binder();
|
||||
// mEarlyWakeupInfo.trace = BaseDepthControllerImpl.class.getName();
|
||||
@@ -184,6 +196,53 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
return folderZoom;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiProperty getFolderBlur() {
|
||||
return folderBlur;
|
||||
}
|
||||
|
||||
/** Flexera Launcher: the blur behind an open folder as it is now, from 0 to 1. */
|
||||
private float folderBlurAmount() {
|
||||
return Utilities.boundToRange(mFolderBlur.value, 0f, 1f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexera Launcher: true while a folder blur is wanted, and after that until it has been
|
||||
* taken off the launcher's surface again.
|
||||
*/
|
||||
protected boolean folderBlurNeedsApplying() {
|
||||
return folderBlurAmount() > 0f || mFolderBlurApplied;
|
||||
}
|
||||
|
||||
/** Flexera Launcher: false where the owner switched the wallpaper's zoom off. */
|
||||
protected boolean isDepthEffectEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexera Launcher: blurs the icons and widgets behind an open folder. That works without
|
||||
* blur across windows as well, so also where the wallpaper has to stay sharp.
|
||||
*
|
||||
* @return {@code false} when no folder blur is wanted, and nothing was done
|
||||
*/
|
||||
private boolean blurFolderBackdrop() {
|
||||
float amount = folderBlurAmount();
|
||||
if (!Utilities.ATLEAST_S || amount <= 0f) {
|
||||
return false;
|
||||
}
|
||||
float radius = Math.max(mCurrentBlur,
|
||||
amount * app.flexera.home.FolderBlur.STRENGTH * mMaxBlurRadius);
|
||||
if (radius < 1f) {
|
||||
// Too little to see, and a blur of no radius is not one the system accepts.
|
||||
mLauncher.getDepthBlurTargets().forEach(target -> target.setRenderEffect(null));
|
||||
return true;
|
||||
}
|
||||
RenderEffect blurEffect = RenderEffect.createBlurEffect(
|
||||
radius, radius, Shader.TileMode.DECAL);
|
||||
mLauncher.getDepthBlurTargets().forEach(target -> target.setRenderEffect(blurEffect));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if cross window blurs are enabled. In other words, whether launcher should use blurs
|
||||
* style UI or fallback style UI.
|
||||
@@ -236,7 +295,9 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
boolean applyImmediately, boolean skipSimilarBlur) {
|
||||
float depth = mDepth;
|
||||
float wallpaperZoom = depth;
|
||||
if (Flags.enableExpressiveFolderExpansion()) {
|
||||
// Flexera Launcher: with the zoom switched off this used to be reached by accident
|
||||
// only; the folder blur now brings it here on purpose, and must not bring the zoom along.
|
||||
if (Flags.enableExpressiveFolderExpansion() && isDepthEffectEnabled()) {
|
||||
wallpaperZoom = Math.max(wallpaperZoom, mWallpaperZoomOnly.value);
|
||||
}
|
||||
IBinder windowToken = mLauncher.getRootView().getWindowToken();
|
||||
@@ -261,6 +322,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
} else {
|
||||
blurAmount = depth;
|
||||
}
|
||||
// Flexera Launcher: an open folder blurs what is behind it, whatever the depth is.
|
||||
float behindFolder = folderBlurAmount() * app.flexera.home.FolderBlur.STRENGTH;
|
||||
blurAmount = Math.max(blurAmount, behindFolder);
|
||||
mFolderBlurApplied = behindFolder > 0f;
|
||||
|
||||
int previousBlur = mCurrentBlur;
|
||||
int newBlur = BlurUtils.supportsBlursOnWindows() && mCrossWindowBlursEnabled
|
||||
@@ -378,6 +443,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
if (!Utilities.ATLEAST_S) {
|
||||
return false;
|
||||
}
|
||||
// Flexera Launcher: an open folder blurs these views in every state, the home screen
|
||||
// at rest included, which the rules below would leave sharp.
|
||||
if (blurFolderBackdrop()) {
|
||||
return true;
|
||||
}
|
||||
StateManager<LauncherState, Launcher> stateManager = mLauncher.getStateManager();
|
||||
LauncherState settledState = stateManager.getState();
|
||||
|
||||
@@ -414,6 +484,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
if (!Utilities.ATLEAST_S) {
|
||||
return;
|
||||
}
|
||||
// Flexera Launcher: the blur behind an open folder is not a stuck one, and stays.
|
||||
if (blurFolderBackdrop()) {
|
||||
return;
|
||||
}
|
||||
mLauncher.getDepthBlurTargets().forEach(target -> {
|
||||
target.setRenderEffect(null);
|
||||
target.invalidate();
|
||||
|
||||
@@ -185,8 +185,16 @@ public class Hotseat extends FrameLayout implements Insettable {
|
||||
// Create an initial page so alpha/translation channels have a target during construction.
|
||||
mPagedView.resetPages(false, null, mActivity.getDeviceProfile());
|
||||
|
||||
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer, ALPHA_CHANNEL_CHANNELS_COUNT);
|
||||
// Flexera Launcher: one more channel than upstream, which hides the icons of a dock
|
||||
// that is switched off. Such a dock has no height, but its icons were still drawn,
|
||||
// just below the screen edge, and the zoom-out behind an open folder brought their
|
||||
// tops into view.
|
||||
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer,
|
||||
ALPHA_CHANNEL_CHANNELS_COUNT + 1);
|
||||
mIconsAlphaChannels.setUpdateVisibility(true);
|
||||
if (!hotseatEnabled) {
|
||||
mIconsAlphaChannels.get(ALPHA_CHANNEL_CHANNELS_COUNT).setValue(0f);
|
||||
}
|
||||
if (mQsb instanceof Reorderable qsbReorderable) {
|
||||
mQsbTranslationX = qsbReorderable.getTranslateDelegate()
|
||||
.getTranslationX(MultiTranslateDelegate.INDEX_NAV_BAR_ANIM);
|
||||
|
||||
@@ -151,8 +151,8 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
final PointF appWidgetScale = profile.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the picture widget takes its cells whole.
|
||||
app.flexera.widget.PictureWidgetProvider.cellInset(child,
|
||||
// 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,
|
||||
@@ -179,8 +179,8 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
final PointF appWidgetScale = dp.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the picture widget takes its cells whole.
|
||||
app.flexera.widget.PictureWidgetProvider.cellInset(child, dp.widgetPadding));
|
||||
// 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);
|
||||
|
||||
@@ -81,6 +81,8 @@ class FolderAnimationSpringBuilderManager(
|
||||
val launcher = launcherDelegate.launcher ?: return
|
||||
val scrim = launcher.scrimView
|
||||
launcherDelegate.launcher?.depthController?.folderZoom?.value = 0f
|
||||
// Flexera Launcher: the blur behind the folder goes with it.
|
||||
launcherDelegate.launcher?.depthController?.folderBlur?.value = 0f
|
||||
if (!launcher.isInState(LauncherState.ALL_APPS)) {
|
||||
scrim.alpha = 1f
|
||||
scrim.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
@@ -57,6 +57,8 @@ class FolderScrimAnimationListener(
|
||||
// Folder.closeComplete's restore path, leaving workspace/hotseat visually dimmed/scaled.
|
||||
SCALE_PROPERTY.set(launcher.workspace, 1f)
|
||||
SCALE_PROPERTY.set(launcher.hotseat, 1f)
|
||||
// Flexera Launcher: and the blur behind the folder, for the same reason.
|
||||
launcher.depthController?.folderBlur?.value = 0f
|
||||
if (Utilities.ATLEAST_S && launcher.stateManager.state.getDepth(launcher) == 0f) {
|
||||
launcher.depthBlurTargets.forEach { it.setRenderEffect(null) }
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) {
|
||||
launcherDelegate.launcher?.depthController?.folderZoom?.let {
|
||||
addWallpaperZoomAnimator(folder.context, animatorSet, folderAnimData.isOpening, it)
|
||||
}
|
||||
addBackdropBlurAnimator(
|
||||
folder.context,
|
||||
animatorSet,
|
||||
folderAnimData.isOpening,
|
||||
launcherDelegate,
|
||||
)
|
||||
iconAnimData.forEach { addContentIconAnimators(folder.context, animatorSet, it) }
|
||||
return FolderSpringAnimatorSet(animatorSet)
|
||||
}
|
||||
@@ -258,6 +264,39 @@ class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexera Launcher: blurs the home screen behind the folder as it opens, and clears it
|
||||
* again as it closes, in step with the dimming. Closing always plays, also when the
|
||||
* setting has been switched off since the folder was opened.
|
||||
*/
|
||||
private fun addBackdropBlurAnimator(
|
||||
context: Context,
|
||||
animatorSet: AnimatorSet,
|
||||
isOpening: Boolean,
|
||||
launcherDelegate: LauncherDelegate,
|
||||
) {
|
||||
val launcher = launcherDelegate.launcher ?: return
|
||||
// As with the dimming: a folder in the app list leaves the list as it is.
|
||||
if (launcher.isInState(LauncherState.ALL_APPS)) return
|
||||
val blur = launcher.depthController?.folderBlur ?: return
|
||||
val wanted =
|
||||
if (isOpening) app.flexera.home.FolderBlur.isOn(context) else blur.value > 0f
|
||||
if (!wanted) return
|
||||
playSpringAnimation(
|
||||
context = context,
|
||||
animatorSet = animatorSet,
|
||||
isOpening = isOpening,
|
||||
startDelay = 0,
|
||||
stiffness = STIFFNESS_LAUNCHER_SCRIM,
|
||||
damping = DAMPING_LAUNCHER_SCRIM,
|
||||
startValue = 0f,
|
||||
endValue = 1f,
|
||||
minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA,
|
||||
property = MULTI_PROPERTY_VALUE,
|
||||
view = blur,
|
||||
)
|
||||
}
|
||||
|
||||
private fun addAlphaAndColorAnimators(
|
||||
folder: Folder,
|
||||
animatorSet: AnimatorSet,
|
||||
|
||||
@@ -40,4 +40,10 @@ interface BaseDepthController {
|
||||
* Property for folder expansion wallpaper zoom.
|
||||
*/
|
||||
val folderZoom: MultiPropertyFactory<*>.MultiProperty?
|
||||
|
||||
/**
|
||||
* Flexera Launcher: property for the blur of the home screen behind an open folder, from 0
|
||||
* (none) to 1 (as strong as it gets).
|
||||
*/
|
||||
val folderBlur: MultiPropertyFactory<*>.MultiProperty?
|
||||
}
|
||||
@@ -88,7 +88,7 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
private boolean mTrackingWidgetUpdate = false;
|
||||
|
||||
// Flexera Launcher: the view that draws this widget when it is one of the launcher's own
|
||||
// (the picture widget); null for every ordinary widget.
|
||||
// (app.flexera.widget.FlexeraWidgets); null for every ordinary widget.
|
||||
@Nullable
|
||||
private View mFlexeraOwnView;
|
||||
|
||||
@@ -162,9 +162,9 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
@Override
|
||||
public void setAppWidget(int appWidgetId, AppWidgetProviderInfo info) {
|
||||
super.setAppWidget(appWidgetId, info);
|
||||
// Flexera Launcher: the launcher's own picture widget is drawn by a view of the
|
||||
// launcher, not by views Android sends over; nothing more is loaded for it.
|
||||
mFlexeraOwnView = app.flexera.widget.PictureWidgetView.attachIfOwn(this, appWidgetId, info);
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user