10 Commits
Author SHA1 Message Date
TMC 4f687957fb Release 0.11.0 2026-11-12 16:34:02 +01:00
TMCandClaude Fable 5.2 b79579a8dd Clock widget: weather line, font weight, and centring in the widget's own frame
Weather: a clock for which the owner picks a place shows a third line, the
temperature and a word for the sky there. The place is looked up by name in the
clock's options; the data comes from Open-Meteo, which needs no account. It is
off until a place is picked, asks at most every 45 minutes while a clock with
weather is in view, sends only the place's coordinates rounded to about a
kilometre, and needs no location permission. The About screen's note on what
goes online says so.

Font weight: thin, light (as before), regular or bold figures.

Centring: the text stood in the middle of the icons' box, which lies in the
upper part of the widget's cells, so it looked too high inside the outline shown
while editing. It now stands in the middle of the widget's own cells, measured
on what is seen of the text.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 16:33:49 +01:00
TMC 516c4efc56 Release 0.10.0 2026-11-12 15:58:08 +01:00
TMCandClaude Fable 5.2 8e6b737894 Clock widget: smaller by default, with a choice of alignment and size
The first version filled its widget with the figures, which the owner found
huge. The figures are now measured against the icons: small is half an icon
tall, medium (the default) most of an icon, large taller than an icon, with the
widget's own height and width as the limit.

The owner chooses left, center or right and the size from the widget's edit
button. The options are a sheet over the home screen, kept per widget, and each
choice shows on the widget at once.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:57:55 +01:00
TMCandClaude Fable 5.2 31ab0d52e1 Add the Clock widget; the QR code leads to the newest APK
Clock is a second widget that comes with the launcher: the time in large light
figures and the date under it, written onto the wallpaper in the colour of the
icon labels. It follows the phone's 12 or 24 hour setting, updates with the
minute, and a tap opens the phone's clock app. Like Pictures it is drawn by the
launcher itself and keeps to the box of the icons in its cells.

What Pictures and Clock share moved into two places: IconFrame works out that
box, and FlexeraWidgets is where Launcher3's widget code asks about the
launcher's own widgets.

The QR code on the About screen now holds the address of the newest APK, so
that scanning it starts the download; the release page's address stays under it
for typing. For that address to work, the release script uploads each APK a
second time under a name that does not change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:42:52 +01:00
TMCandClaude Fable 5.2 04e78205dd Release script: do not take Android's own crash during an APK swap for the app's
When the release build is the emulator's home app, Android restarts it while
the files are still being replaced, and that start can fail inside
ActivityThread.handleBindApplication. The start-up check now waits for the swap
to settle, stops the app, and judges a fresh start.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:57:49 +01:00
TMC cef6929a41 Release 0.9.0 2026-11-12 14:36:21 +01:00
TMCandClaude Fable 5.2 fba65e091f Add quick launch: a swipe past the first or last home page opens a chosen app
Swiping on from the first home screen page, towards the page that is not
there, now does what the owner chose under Settings, Gestures, Quick launch:
open an app, or any other of the launcher's gesture actions. The last page has
a choice of its own.

The stretch the pages already show at either end is an edge effect; quick
launch replaces it with one that also counts how far the finger has pulled.
Lifting the finger after a pull of about a seventh of the screen, or after a
flick, does the action; a short pull or one that is taken back does nothing.
An app opens out of the screen edge the finger pulled from.

From Android 12, where the stretch is Android's own. Infinite scrolling takes
those swipes for itself, so the two do not combine.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:36:03 +01:00
TMC b696a376b2 Release 0.8.1 2026-11-12 13:52:28 +01:00
TMCandClaude Fable 5.2 bfc2a0ea36 Ask for local network access when the update server is on the phone's own network
On Android 17 an app that targets it may not connect to devices on the local
network without the new ACCESS_LOCAL_NETWORK permission; a blocked connection is
dropped and ends as a timeout. At the owner's home the update server's name
leads to its address on the home network, so the check for updates failed there
with "did not answer" while the same server answered every other device.

The updater now recognises this case before it connects (Android 17, permission
not granted, server address inside one of the phone's own network ranges) and
says so at once. A tap on that message asks for the permission and runs the
check again; a refusal that Android no longer asks about leads to the app's
page in the phone's settings.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:52:16 +01:00
23 changed files with 1723 additions and 145 deletions

No files matched your search

+27
View File
@@ -12,6 +12,13 @@
<!-- Lets the app hand a downloaded update to Android's installer. The owner still confirms each install. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
Since Android 17 an app needs this to reach a device on the network the phone itself is on.
The update server can be such a device: at the owner's home its name leads to its address
on the home network. Asked for only when a check for updates is stopped by it (About screen).
-->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application>
<!--
@@ -30,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"
+27 -10
View File
@@ -211,6 +211,13 @@ if ($NoSmokeTest) {
if ($LASTEXITCODE -ne 0 -or -not ($install -match 'Success')) {
throw "Could not install the APK on $emulator. If an older build signed with another key is installed there, uninstall it first: adb uninstall $AppId"
}
# When this app is the emulator's home app, Android restarts it the moment the old copy is
# gone, and that first start can die inside Android itself while the files are still being
# swapped (a NullPointerException in ActivityThread.handleBindApplication, with no line of
# this app in it). That is not the new build crashing: let the swap settle, then start
# from a clean slate and judge what happens from there.
Start-Sleep -Seconds 6
& $Adb -s $emulator shell am force-stop $AppId
& $Adb -s $emulator logcat -b crash -c
& $Adb -s $emulator shell am start -n $HomeActivity | Out-Null
Write-Host "Installed on $emulator and started; watching for $SmokeTestSeconds seconds..."
@@ -244,23 +251,33 @@ $release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) '
Step "6/7 Uploading the APK"
$assetName = "FlexeraLauncher-$Version.apk"
$stream = [IO.File]::OpenRead($apk)
# The APK goes up twice. Once under a name with its version, which is what a person looking at
# the release sees and what the app's updater downloads. And once under a name that never
# changes, so that <server>/<repo>/releases/download/latest/FlexeraLauncher.apk always leads to
# the newest APK: the address in the QR code on the app's About screen (FlexeraUpdater.LATEST_APK_URL).
# The versioned one goes first, because the updater takes the first .apk it finds.
$assetNames = @("FlexeraLauncher-$Version.apk", 'FlexeraLauncher.apk')
$uploaded = @()
try {
$file = New-Object System.Net.Http.StreamContent($stream)
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form = New-Object System.Net.Http.MultipartFormDataContent
$form.Add($file, 'attachment', $assetName)
$asset = Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) 'Uploading the APK'
foreach ($assetName in $assetNames) {
$stream = [IO.File]::OpenRead($apk)
try {
$file = New-Object System.Net.Http.StreamContent($stream)
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form = New-Object System.Net.Http.MultipartFormDataContent
$form.Add($file, 'attachment', $assetName)
$uploaded += Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) "Uploading $assetName"
} finally {
$stream.Dispose()
}
}
} catch {
# A release without its APK would make every installed copy report a failed check.
$client.DeleteAsync("$Api/releases/$($release.id)").Result | Out-Null
$client.DeleteAsync("$Api/tags/v$Version").Result | Out-Null
throw
} finally {
$stream.Dispose()
}
Write-Host "Uploaded: $($asset.browser_download_url)"
$uploaded | ForEach-Object { Write-Host "Uploaded: $($_.browser_download_url)" }
# --- 7. Source tag -------------------------------------------------------------------------------
@@ -0,0 +1,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>
+53 -3
View File
@@ -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>
@@ -92,6 +92,55 @@
<string name="flexera_scrub_from_home">Scrub from the home screen</string>
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
<string name="flexera_quick_launch">Quick launch</string>
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
<string name="flexera_quick_launch_first_page">Swipe past the first page</string>
<string name="flexera_quick_launch_last_page">Swipe past the last page</string>
<!-- The clock widget that comes with the launcher (app.flexera.widget) -->
<!-- Its name in the list of widgets -->
<string name="flexera_clock_widget">Clock</string>
<string name="flexera_clock_widget_description">The time and the date, lined up with your icons</string>
<!-- The clock widget's options, reached from its edit button -->
<string name="flexera_clock_widget_alignment">Alignment</string>
<string name="flexera_clock_widget_left">Left</string>
<string name="flexera_clock_widget_center">Center</string>
<string name="flexera_clock_widget_right">Right</string>
<string name="flexera_clock_widget_size">Size</string>
<string name="flexera_clock_widget_small">Small</string>
<string name="flexera_clock_widget_medium">Medium</string>
<string name="flexera_clock_widget_large">Large</string>
<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>
@@ -111,6 +160,7 @@
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
<string name="flexera_update_failed_local_network">%1$s is on the same network as this phone, and Android lets an app reach devices there only with your permission, which it calls Nearby devices. Tap to allow it. The check then runs again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Flexera Launcher's clock widget (app.flexera.widget.ClockWidgetProvider). Four cells by two
when first placed, resizable from two by one upwards. The launcher draws it itself, so the
layouts named here are never shown on the home screen; previewLayout is what makes the widget
list ask for a live preview, which the launcher also draws. Nothing has to be set up when it
is added (configuration_optional); its edit button opens the options for alignment and size.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:description="@string/flexera_clock_widget_description"
android:configure="app.flexera.widget.ClockWidgetConfigActivity"
android:initialLayout="@layout/flexera_clock_widget_placeholder"
android:previewLayout="@layout/flexera_clock_widget_placeholder"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable|configuration_optional" />
+167
View File
@@ -0,0 +1,167 @@
package app.flexera.home
import android.app.ActivityOptions
import android.content.Context
import android.content.pm.LauncherApps
import android.os.Build
import android.util.Log
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import app.lawnchair.LawnchairLauncher
import app.lawnchair.gestures.config.GestureHandlerConfig
import app.lawnchair.gestures.handlers.OpenAppTarget
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.Launcher
import com.android.launcher3.LauncherState
import com.android.launcher3.util.EdgeEffectCompat
/**
* Quick launch: a swipe on from the first home screen page, towards the page that is not there,
* opens an app the owner has chosen (or does another of the launcher's gesture actions); the
* same past the last page.
*
* The home screen already answers such a swipe by stretching and springing back. That stretch is
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
*/
object QuickLaunch {
private const val TAG = "QuickLaunch"
/** How far the finger has to pull past the page, as a share of the screen's width. */
private const val ARM_DISTANCE = 0.14f
/** A flick counts from this much pull, at [FLICK_DP_PER_SECOND] or faster. */
private const val FLICK_DISTANCE = 0.02f
private const val FLICK_DP_PER_SECOND = 600f
/**
* The edge effect for one side of the home screen pages. [atLeftEdge] says which side of
* the screen; which page that is the end of depends on the reading direction ([rtl]).
*/
@JvmStatic
fun edgeEffect(workspace: View, launcher: Launcher, atLeftEdge: Boolean, rtl: Boolean): EdgeEffectCompat =
Effect(workspace, launcher, firstPage = atLeftEdge != rtl, atLeftEdge = atLeftEdge)
/** What the owner has chosen for a swipe past the first or the last page. */
private fun choice(context: Context, firstPage: Boolean): GestureHandlerConfig {
val prefs = PreferenceManager2.getInstance(context)
return if (firstPage) {
prefs.firstPageSwipeGestureHandler.firstCached()
} else {
prefs.lastPageSwipeGestureHandler.firstCached()
}
}
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
val choice = choice(launcher, firstPage)
if (choice is GestureHandlerConfig.NoOp) return
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
if (target is OpenAppTarget.App) {
// The app opens out of the screen edge the finger pulled from, as near to a page
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
// the launcher's own making only for the phone's built-in launcher.
val root = launcher.rootView
val options = ActivityOptions.makeClipRevealAnimation(
root,
if (fromLeft) 0 else root.width - 1,
0,
1,
root.height,
)
try {
launcher.getSystemService(LauncherApps::class.java)
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
} catch (e: RuntimeException) {
// The chosen app is gone or cannot be started; nothing to open.
Log.w(TAG, "The quick launch app could not be opened", e)
}
return
}
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
}
private class Effect(
private val workspace: View,
private val launcher: Launcher,
private val firstPage: Boolean,
private val atLeftEdge: Boolean,
) : EdgeEffectCompat(workspace.context) {
/** How far the finger has pulled in this touch, as a share of the screen's width. */
private var pulled = 0f
/** Whether there is anything to do on this side; looked up when a pull begins. */
private var active = false
private var armed = false
private var flicked = false
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
if (pulled == 0f && deltaDistance > 0f) {
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
launcher.isInState(LauncherState.NORMAL) &&
!launcher.dragController.isDragging
}
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
val nowArmed = active && pulled >= ARM_DISTANCE
if (nowArmed != armed) {
armed = nowArmed
// A tick when the pull is far enough, and again when it is taken back.
tick()
}
return consumed
}
override fun onFlingVelocity(velocity: Int) {
// A pull at the left side is a finger moving right, which is a positive speed.
val outward = if (atLeftEdge) velocity else -velocity
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
}
override fun onRelease(ev: MotionEvent) {
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
val fire = lifted && active && (armed || flicked)
val tickNow = fire && !armed
reset()
super.onRelease(ev)
if (fire) {
if (tickNow) tick()
// After the page has finished handling this touch.
workspace.post { launch(launcher, firstPage, fromLeft = atLeftEdge) }
}
}
override fun onRelease() {
reset()
super.onRelease()
}
override fun finish() {
reset()
super.finish()
}
private fun reset() {
pulled = 0f
active = false
armed = false
flicked = false
}
private fun tick() {
val feedback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE
} else {
HapticFeedbackConstants.CLOCK_TICK
}
// The pages themselves have haptics switched off; the window's root has not.
launcher.rootView.performHapticFeedback(feedback)
}
}
}
+56 -7
View File
@@ -1,9 +1,13 @@
package app.flexera.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.provider.Settings
import android.text.format.Formatter
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@@ -196,7 +200,11 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
groupRows(updateRows)
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
item {
PreferenceGroupItem {
ShareCard(scanUrl = FlexeraUpdater.LATEST_APK_URL, pageUrl = FlexeraUpdater.RELEASES_PAGE_URL)
}
}
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
groupRows(
@@ -246,6 +254,21 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
@Composable
private fun UpdateStatusRow(state: UpdateState) {
val context = LocalContext.current
// Android 17 asks the owner before an app may reach devices on the local network, and an
// update server at home is one. The answer starts the check again; a "no" that Android no
// longer asks about leads to the app's page in the phone's settings instead.
val askForLocalNetwork = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
val activity = context.findActivity()
if (granted || activity == null ||
activity.shouldShowRequestPermissionRationale(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
) {
FlexeraUpdater.check(context)
} else {
openAppSettings(context)
}
}
when (state) {
UpdateState.Idle -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
@@ -296,7 +319,13 @@ private fun UpdateStatusRow(state: UpdateState) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = checkFailureText(state),
onClick = { FlexeraUpdater.check(context) },
onClick = {
if (state.problem == CheckProblem.LOCAL_NETWORK) {
askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
} else {
FlexeraUpdater.check(context)
}
},
)
} else {
ClickablePreference(
@@ -316,11 +345,12 @@ private fun UpdateStatusRow(state: UpdateState) {
}
/**
* What the owner holds up for someone who wants the app: a QR code that opens the release page,
* with the address under it for anyone who would rather type it.
* What the owner holds up for someone who wants the app: a QR code that downloads the newest
* APK ([scanUrl]), with the address of the release page under it for anyone who would rather
* type one ([pageUrl]); that one is shorter and shows all versions.
*/
@Composable
private fun ShareCard(url: String) {
private fun ShareCard(scanUrl: String, pageUrl: String) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -328,7 +358,7 @@ private fun ShareCard(url: String) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCode(
text = url,
text = scanUrl,
description = stringResource(id = R.string.flexera_about_share_qr_description),
modifier = Modifier.size(QR_CODE_SIZE),
)
@@ -340,7 +370,7 @@ private fun ShareCard(url: String) {
)
Spacer(Modifier.height(4.dp))
Text(
text = url.removePrefix("https://"),
text = pageUrl.removePrefix("https://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -393,6 +423,7 @@ private fun checkFailureText(state: UpdateState.Failed): String {
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
CheckProblem.LOCAL_NETWORK -> stringResource(id = R.string.flexera_update_failed_local_network, host)
CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
}
val detail = state.detail ?: return reason
@@ -445,3 +476,21 @@ private fun openNotificationSettings(context: Context) {
context.startActivity(intent)
}
}
/** Opens this app's page in the phone's settings, where its permissions are. */
private fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}
/** The activity this context belongs to, if it belongs to one. */
private fun Context.findActivity(): Activity? {
var current: Context? = this
while (current != null) {
if (current is Activity) return current
current = (current as? ContextWrapper)?.baseContext
}
return null
}
@@ -9,7 +9,9 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
@@ -23,6 +25,8 @@ import com.android.launcher3.R
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.URL
import java.net.UnknownHostException
import javax.net.ssl.SSLException
@@ -74,6 +78,14 @@ enum class CheckProblem {
/** The server was found but did not answer in time, or refused the connection. */
NO_ANSWER,
/**
* The server's address is on the network the phone itself is on (a server at home, reached
* over the home Wi-Fi), and Android has not been told to let this app reach devices there.
* Since Android 17 that takes a permission of its own; without it the connection is
* dropped without a word and looks like a server that does not answer.
*/
LOCAL_NETWORK,
/** The encrypted connection could not be set up. */
NOT_SECURE,
@@ -137,6 +149,13 @@ object FlexeraUpdater {
/** The page listing every release, for the About screen's "All versions" link. */
const val RELEASES_PAGE_URL = "$SERVER/$RELEASE_REPOSITORY/releases"
/**
* Always the APK of the newest release: the server sends "latest" on to the newest release,
* and every release carries its APK under this one name as well (flexera/release.ps1). What
* the QR code on the About screen holds, so that scanning it starts the download.
*/
const val LATEST_APK_URL = "$SERVER/$RELEASE_REPOSITORY/releases/download/latest/FlexeraLauncher.apk"
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 30_000
@@ -216,7 +235,12 @@ object FlexeraUpdater {
nextAutomaticCheckAt = System.currentTimeMillis() + RETRY_INTERVAL_MS
scope.launch {
val result = runCatching { fetchLatestRelease(app) }
val result = runCatching {
// Android drops a connection it does not allow without a word, and the check
// would wait out its time limit. Say at once what is needed instead.
if (blockedFromLocalNetwork(app)) throw LocalNetworkBlockedException()
fetchLatestRelease(app)
}
prefs(app).edit()
.putLong(KEY_LAST_ATTEMPT, System.currentTimeMillis())
.putBoolean(KEY_LAST_ATTEMPT_OK, result.isSuccess)
@@ -227,7 +251,7 @@ object FlexeraUpdater {
Log.w(TAG, "Update check failed", error)
// A background check that fails stays quiet: the About screen keeps what it showed
// before, including an update that is already waiting. It tries again in an hour.
mutableState.value = if (automatic) before else failedCheck(error)
mutableState.value = if (automatic) before else failedCheck(app, error)
return@launch
}
@@ -346,17 +370,87 @@ object FlexeraUpdater {
private class NoNetworkPermissionException : IOException("This app has no network permission")
private class ServerErrorException(status: Int) : IOException("The server answered HTTP $status")
private class BadAnswerException(message: String) : IOException(message)
private class LocalNetworkBlockedException : IOException("Android does not let this app reach the local network")
/**
* The permission Android 17 asks of an app before it may reach devices on the local
* network. Named by its text, because older Android versions, which the app also runs on,
* do not know it.
*/
const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK"
/** The first Android version that keeps apps from the local network: Android 17. */
private const val LOCAL_NETWORK_SDK = 37
/**
* True when Android is keeping this app from the update server because the server is on
* the phone's own network and the owner has not allowed local network access. Looks the
* server's name up, so not for the main thread.
*/
private fun blockedFromLocalNetwork(context: Context): Boolean {
if (Build.VERSION.SDK_INT < LOCAL_NETWORK_SDK) return false
if (context.checkSelfPermission(LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED) return false
return try {
// Looking a name up is allowed without the permission, also at a resolver at home.
InetAddress.getAllByName(URL(SERVER).host).any(::isOnOwnNetwork)
} catch (e: IOException) {
false
} catch (e: SecurityException) {
false
}
}
/**
* True for an address that this phone reaches directly, without a router in between: one
* inside the range of one of its own network connections. That is what Android counts as
* the local network.
*/
private fun isOnOwnNetwork(address: InetAddress): Boolean {
if (address.isLoopbackAddress) return false
if (address.isLinkLocalAddress) return true
val target = address.address
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
for (network in interfaces) {
if (network.isLoopback || !network.isUp) continue
for (own in network.interfaceAddresses) {
val base = own.address?.address ?: continue
if (base.size != target.size) continue
var bits = own.networkPrefixLength.toInt()
if (bits <= 0) continue
var same = true
for (i in base.indices) {
if (bits <= 0) break
val mask = if (bits >= 8) 0xFF else (0xFF shl (8 - bits)) and 0xFF
if ((base[i].toInt() and mask) != (target[i].toInt() and mask)) {
same = false
break
}
bits -= 8
}
if (same) return true
}
}
return false
}
/** Turns the error of a failed check into a state that says what kind of failure it was. */
private fun failedCheck(error: Throwable): UpdateState.Failed {
val problem = when (error) {
private fun failedCheck(context: Context, error: Throwable): UpdateState.Failed {
val kind = when (error) {
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
is LocalNetworkBlockedException -> CheckProblem.LOCAL_NETWORK
is UnknownHostException -> CheckProblem.SERVER_NOT_FOUND
is SSLException -> CheckProblem.NOT_SECURE
is ServerErrorException -> CheckProblem.SERVER_ERROR
is BadAnswerException, is JSONException -> CheckProblem.BAD_ANSWER
else -> CheckProblem.NO_ANSWER
}
// A server that did not answer may still be one that Android kept this app from, where
// the look before the check could not tell (the network changed in between, say).
val problem = if (kind == CheckProblem.NO_ANSWER && blockedFromLocalNetwork(context)) {
CheckProblem.LOCAL_NETWORK
} else {
kind
}
// Android's own error classes keep their names in a release build; this file's are
// renamed by the shrinker, and their messages say everything anyway.
val name = error.javaClass.name.takeIf { it.startsWith("java") || it.startsWith("android") || it.startsWith("org.json") }
@@ -0,0 +1,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)}" +
"&current=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
}
}
}
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.8.0
versionName=0.11.0
@@ -874,6 +874,18 @@ class PreferenceManager2 @Inject constructor(
defaultValue = GestureHandlerConfig.NoOp,
)
// Flexera Launcher: quick launch. What a swipe on from the first home screen page, towards
// the page that is not there, does; and the same past the last page (app.flexera.home.QuickLaunch).
val firstPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
key = stringPreferencesKey("flexera_first_page_swipe_gesture_handler"),
defaultValue = GestureHandlerConfig.NoOp,
)
val lastPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
key = stringPreferencesKey("flexera_last_page_swipe_gesture_handler"),
defaultValue = GestureHandlerConfig.NoOp,
)
val autoUpdaterNightly = preference(
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
@@ -1,5 +1,6 @@
package app.lawnchair.ui.preferences.destinations
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -53,6 +54,23 @@ fun GesturePreferences(
label = stringResource(id = R.string.gesture_back_tap),
)
}
// Flexera Launcher: quick launch, a swipe on past the first or the last home screen page
// (app.flexera.home.QuickLaunch). It rides on Android's own page stretch, new in Android 12.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PreferenceGroup(
heading = stringResource(id = R.string.flexera_quick_launch),
description = stringResource(id = R.string.flexera_quick_launch_description),
) {
GestureHandlerPreference(
adapter = prefs.firstPageSwipeGestureHandler.getAdapter(),
label = stringResource(id = R.string.flexera_quick_launch_first_page),
)
GestureHandlerPreference(
adapter = prefs.lastPageSwipeGestureHandler.getAdapter(),
label = stringResource(id = R.string.flexera_quick_launch_last_page),
)
}
}
PreferenceGroup(heading = stringResource(id = R.string.sleep_mode_label)) {
ListPreference(
adapter = prefs.sleepMode.getAdapter(),
@@ -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);
+11 -1
View File
@@ -372,6 +372,13 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
setHapticFeedbackEnabled(false);
initWorkspace();
// Flexera Launcher: quick launch. The stretch at either end of the pages doubles as a
// gesture that opens an app the owner has chosen (app.flexera.home.QuickLaunch). From
// Android 12, where that stretch is Android's own; before, Lawnchair draws one itself.
if (Utilities.ATLEAST_S) {
mEdgeGlowLeft = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, true, mIsRtl);
mEdgeGlowRight = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, false, mIsRtl);
}
// Disable multitouch across the workspace/all apps/customize tray
setMotionEventSplittingEnabled(true);
@@ -1476,7 +1483,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
public void setLauncherOverlay(LauncherOverlayTouchProxy overlay) {
final EdgeEffectCompat newEffect;
if (overlay == null) {
newEffect = new EdgeEffectCompat(getContext());
// Flexera Launcher: without a feed, the first page's end is quick launch again.
newEffect = Utilities.ATLEAST_S
? app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, !mIsRtl, mIsRtl)
: new EdgeEffectCompat(getContext());
mOverlayEdgeEffect = null;
} else {
newEffect = mOverlayEdgeEffect = new OverlayEdgeEffect(getContext(), overlay);
@@ -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;
}