4 Commits
Author SHA1 Message Date
TMC 8cfa2fdc84 Release 0.7.0 2026-11-12 12:09:20 +01:00
TMCandClaude Fable 5.2 d0d1c031d0 Add a row size setting and give the screen edge back to Android's Back swipe
Row size (Settings, App drawer, Style): Compact is the list as it was, Comfy
has taller rows, larger icons and slightly larger names and letter headers.

In the open drawer the alphabet no longer reserves the screen edge. Its letters
rest 30dp from the edge, clear of the strip where Android starts Back. A touch
that starts on that strip becomes a scrub once it moves up or down or rests for
a moment; a sideways swipe is left to Android and goes back. The band that
starts a scrub from the home screen is narrower than the one in the drawer.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 12:09:11 +01:00
TMC 494364ffa2 Release 0.6.1 2026-11-12 10:43:09 +01:00
TMCandClaude Fable 5.2 80a56c01c9 Say why a check for updates failed
The owner's phone answered a manual check with "Couldn't reach the update server" while
the same published version found the update on the emulator and the server answered from
the PC. The message gave no way to tell a missing network permission from a VPN, a name
that does not resolve, or a server error.

- A failed check now carries what kind of failure it was (no network permission, server
  not found, no answer, no secure connection, server error, unreadable answer) and the
  error's own words. The About screen says the kind in plain words with what to try, and
  shows the words under it as "Details".
- The no-permission case is told apart before any request is made; some Android systems
  let the owner switch the network off per app.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:43:07 +01:00
10 changed files with 316 additions and 48 deletions

No files matched your search

+18 -4
View File
@@ -8,6 +8,12 @@
<dimen name="flexera_drawer_row_icon_size">34dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">16dp</dimen>
<dimen name="flexera_drawer_row_text_size">15sp</dimen>
<!-- The "comfy" row size the owner can choose instead (Settings, App drawer): a little
larger all round, between compact and the first version. -->
<dimen name="flexera_drawer_row_height_comfy">58dp</dimen>
<dimen name="flexera_drawer_row_icon_size_comfy">40dp</dimen>
<dimen name="flexera_drawer_row_icon_gap_comfy">18dp</dimen>
<dimen name="flexera_drawer_row_text_size_comfy">16sp</dimen>
<!-- Row padding on the edge without the wave alphabet. The search bar lines up with it. -->
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
<!-- Row padding on the wave alphabet's edge, which is the end edge unless the owner moves it
@@ -15,10 +21,15 @@
<dimen name="flexera_drawer_row_padding_end">56dp</dimen>
<!-- Wave alphabet (PRD AD-4 to AD-6). -->
<!-- How wide a band along the edge starts a scrub when touched. -->
<dimen name="flexera_wave_touch_width">40dp</dimen>
<!-- Where the letters rest, measured from the edge. -->
<dimen name="flexera_wave_edge_inset">16dp</dimen>
<!-- How wide a band along the edge starts a scrub when touched in the open drawer. -->
<dimen name="flexera_wave_touch_width">60dp</dimen>
<!-- The same band on the home screen, where nothing shows it: narrower, so that fewer
swipes that only pass near the edge are taken for a scrub (PRD AD-10). -->
<dimen name="flexera_wave_home_touch_width">40dp</dimen>
<!-- Where the letters rest, measured from the edge. Inward of the strip of edge where
Android starts its Back swipe (about 24dp), so a thumb on the letters is not on it.
(First version: 16dp, with the whole edge reserved, which left no Back swipe.) -->
<dimen name="flexera_wave_edge_inset">30dp</dimen>
<dimen name="flexera_wave_letter_size">12sp</dimen>
<!-- Letters never sit further apart than this, however few there are. Close together, a
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
@@ -63,4 +74,7 @@
<dimen name="flexera_drawer_header_height">34dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom">4dp</dimen>
<dimen name="flexera_drawer_header_text_size">12sp</dimen>
<dimen name="flexera_drawer_header_height_comfy">38dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom_comfy">5dp</dimen>
<dimen name="flexera_drawer_header_text_size_comfy">13sp</dimen>
</resources>
+12 -1
View File
@@ -74,6 +74,9 @@
<!-- The drawer's wave alphabet -->
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
<string name="flexera_alphabet_description">Alphabet</string>
<string name="flexera_row_size">Row size</string>
<string name="flexera_row_size_compact">Compact</string>
<string name="flexera_row_size_comfy">Comfy</string>
<string name="flexera_alphabet_search">Search</string>
<!-- App drawer settings (app.flexera.ui.FlexeraDrawerSettings) -->
<string name="flexera_notification_previews">Notification previews</string>
@@ -90,7 +93,15 @@
<string name="flexera_update_check_idle">Flexera Launcher also checks on its own, once a day</string>
<string name="flexera_update_checking">Checking for updates…</string>
<string name="flexera_update_up_to_date">You have the latest version</string>
<string name="flexera_update_check_failed">Couldn\'t reach the update server. Check your connection, then tap to try again.</string>
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Tap to try again later.</string>
<!-- %1$s is the error in the system\'s own words, for whoever looks for the cause -->
<string name="flexera_update_failed_detail">Details: %1$s</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_download">Download version %1$s</string>
<!-- %1$s is a file size such as 24 MB -->
+70 -14
View File
@@ -47,9 +47,54 @@ object FlexeraDrawer {
@JvmStatic
fun sectionHeader(sectionName: String): AdapterItem = SectionHeaderItem(sectionName)
/** The sizes the owner can give the list's rows (Settings, App drawer). */
enum class RowSize(val key: String) { COMPACT("compact"), COMFY("comfy") }
/** The row size in use. Changing the setting recreates the launcher. */
@JvmStatic
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View =
inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false).also(::applySidePadding)
fun rowSize(context: Context): RowSize {
val key = PreferenceManager.getInstance(context).flexeraDrawerRowSize.get()
return RowSize.entries.firstOrNull { it.key == key } ?: RowSize.COMPACT
}
/** Picks the dimension of the row size in use. */
private fun sized(context: Context, compact: Int, comfy: Int): Int =
if (rowSize(context) == RowSize.COMFY) comfy else compact
/** How tall a letter header is. */
@JvmStatic
fun headerHeight(context: Context): Int = context.resources.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_header_height, R.dimen.flexera_drawer_header_height_comfy),
)
@JvmStatic
fun createSectionHeader(inflater: LayoutInflater, parent: ViewGroup): View {
val header = inflater.inflate(R.layout.flexera_all_apps_section_header, parent, false) as TextView
val context = header.context
val res = context.resources
// The layout carries the compact sizes; the row size in use has the last word.
header.layoutParams.height = headerHeight(context)
header.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
res.getDimension(
sized(context, R.dimen.flexera_drawer_header_text_size, R.dimen.flexera_drawer_header_text_size_comfy),
),
)
header.setPadding(
header.paddingLeft,
header.paddingTop,
header.paddingRight,
res.getDimensionPixelSize(
sized(
context,
R.dimen.flexera_drawer_header_padding_bottom,
R.dimen.flexera_drawer_header_padding_bottom_comfy,
),
),
)
applySidePadding(header)
return header
}
/**
* True when the owner has moved the wave alphabet to the left edge (PRD AD-15). Changing the
@@ -93,10 +138,17 @@ object FlexeraDrawer {
// Other icons share the "all apps" display type (search results, for one); this tag
// tells list rows apart from them.
icon.setTag(R.id.flexera_drawer_row, true)
val textSize = res.getDimension(R.dimen.flexera_drawer_row_text_size)
icon.layoutParams.height = rowHeight(icon.context)
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
val context = icon.context
icon.layoutParams.height = rowHeight(context)
icon.setIconSizeOverride(
res.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_row_icon_size, R.dimen.flexera_drawer_row_icon_size_comfy),
),
)
icon.compoundDrawablePadding = res.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_row_icon_gap, R.dimen.flexera_drawer_row_icon_gap_comfy),
)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, rowTextSize(context))
applySidePadding(icon)
// The drawer has no background, so names sit directly on the dimmed wallpaper: white,
// with a soft shadow to stay readable over light areas.
@@ -118,13 +170,17 @@ object FlexeraDrawer {
* the row grows instead.
*/
@JvmStatic
fun rowHeight(context: Context): Int {
val res = context.resources
return max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(res.getDimension(R.dimen.flexera_drawer_row_text_size) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
}
fun rowHeight(context: Context): Int = max(
context.resources.getDimensionPixelSize(
sized(context, R.dimen.flexera_drawer_row_height, R.dimen.flexera_drawer_row_height_comfy),
),
(rowTextSize(context) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
/** The size of an app name in a row, in pixels. */
private fun rowTextSize(context: Context): Float = context.resources.getDimension(
sized(context, R.dimen.flexera_drawer_row_text_size, R.dimen.flexera_drawer_row_text_size_comfy),
)
/** The least a row's height may be, in name text sizes: two lines of text and some air. */
private const val ROW_HEIGHT_IN_TEXT_SIZES = 2.4f
@@ -304,7 +360,7 @@ object FlexeraDrawer {
): Boolean {
val wave = waveAlphabet(appsView) ?: return false
val offset = layoutOffset(wave, dragLayer)
return wave.isOnStripBand(ev.x - offset.x, ev.y - offset.y)
return wave.isOnHomeBand(ev.x - offset.x, ev.y - offset.y)
}
/**
@@ -13,6 +13,7 @@ import android.os.Bundle
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewTreeObserver
import android.view.animation.DecelerateInterpolator
import android.widget.SeekBar
@@ -50,7 +51,9 @@ import kotlin.math.roundToInt
* as the last.
*
* The view covers the whole drawer so it has room to draw the wave, but it only takes touches
* that start on the strip; every other touch falls through to the list underneath.
* that start on the strip; every other touch falls through to the list underneath. The very
* edge is also where Android starts its Back swipe. The strip leaves that to Android: a touch
* there becomes a scrub only once it moves up or down or rests, and a sideways swipe goes back.
*
* [beginScrub], [moveScrub] and [endScrub] are public so that another gesture, such as swipe up
* and hold from the home screen (PRD AD-17), can hand its touch over to the alphabet.
@@ -79,7 +82,9 @@ class WaveAlphabetView(
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
private val headerHeight = res.getDimensionPixelSize(R.dimen.flexera_drawer_header_height)
private val headerHeight = FlexeraDrawer.headerHeight(context)
private val homeTouchWidth = res.getDimension(R.dimen.flexera_wave_home_touch_width)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
/** The section name Launcher3 gives the work profile's info card, an "i" in a circle. */
private val workInfoSection = res.getString(R.string.work_profile_edu_section)
@@ -153,11 +158,25 @@ class WaveAlphabetView(
private var fingerY = 0f
private var selected = -1
// A touch that began on the edge strip Android uses for Back, before it is known whether
// it is a scrub: where it went down, where it is now, and the timer that makes a touch
// at rest a scrub.
private var waitingForBack = false
private var downX = 0f
private var downY = 0f
private var lastX = 0f
private var lastY = 0f
private val scrubAfterRest = Runnable {
if (waitingForBack) {
waitingForBack = false
beginScrub(lastX, lastY)
}
}
/** 0 when the strip is at rest, 1 when the wave is fully out. Animated on touch down and up. */
private var press = 0f
private var pressAnimator: ValueAnimator? = null
private val exclusionRect = Rect()
private val location = IntArray(2)
private val otherLocation = IntArray(2)
@@ -250,16 +269,9 @@ class WaveAlphabetView(
restingLetterSize = min(letterSize, spacing * MAX_LETTER_SHARE_OF_SLOT)
}
// The strip sits where Android's back swipe starts. A home app may reserve the whole
// edge, so the scrub never turns into Back.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (onLeft) {
exclusionRect.set(0, stripTop.toInt(), touchWidth.toInt(), stripBottom.toInt())
} else {
exclusionRect.set((width - touchWidth).toInt(), stripTop.toInt(), width, stripBottom.toInt())
}
systemGestureExclusionRects = listOf(exclusionRect)
}
// The strip does not reserve the edge for itself: Android's Back swipe starts there,
// and the owner closes the drawer with it. The two share the edge by direction, see
// onTouchEvent.
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -275,6 +287,7 @@ class WaveAlphabetView(
override fun onDetachedFromWindow() {
viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
stopWaitingForBack()
pressAnimator?.cancel()
super.onDetachedFromWindow()
}
@@ -384,13 +397,29 @@ class WaveAlphabetView(
* True when this point lies on the band along the edge that the letters occupy, whether or
* not the drawer is open. Scrub from home (PRD AD-10) asks this while the drawer is closed.
*/
fun isOnStripBand(x: Float, y: Float): Boolean {
fun isOnStripBand(x: Float, y: Float): Boolean = isOnBand(x, y, touchWidth)
/** The same for the home screen, where the band is narrower because nothing shows it. */
fun isOnHomeBand(x: Float, y: Float): Boolean = isOnBand(x, y, homeTouchWidth)
private fun isOnBand(x: Float, y: Float, bandWidth: Float): Boolean {
if (layoutDirty) computeLayout()
if (letters.isEmpty()) return false
val onEdge = if (onLeft) x <= touchWidth else x >= width - touchWidth
val onEdge = if (onLeft) x <= bandWidth else x >= width - bandWidth
return onEdge && y >= stripTop - verticalMargin && y <= stripBottom + verticalMargin
}
/**
* True when a touch at [x] starts on the strip of edge where Android may begin its Back
* swipe. With three-button navigation there is no such strip.
*/
private fun isInBackSwipeZone(x: Float): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false
@Suppress("DEPRECATION")
val insets = rootWindowInsets?.systemGestureInsets ?: return false
return if (onLeft) x < insets.left else x > width - insets.right
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
@@ -398,16 +427,50 @@ class WaveAlphabetView(
if (!isOnStrip(event.x, event.y)) return false
// Keep the drawer from scrolling or closing under the thumb.
parent?.requestDisallowInterceptTouchEvent(true)
beginScrub(event.x, event.y)
downX = event.x
downY = event.y
lastX = event.x
lastY = event.y
if (isInBackSwipeZone(event.x)) {
// This may be the start of Android's Back swipe, which moves sideways.
// Show nothing until the touch says which it is: a move up or down or a
// short rest is a scrub, a move sideways is left to Android.
waitingForBack = true
postDelayed(scrubAfterRest, REST_BEFORE_SCRUB_MS)
} else {
beginScrub(event.x, event.y)
}
return true
}
MotionEvent.ACTION_MOVE -> {
if (!scrubbing) return false
moveScrub(event.x, event.y)
lastX = event.x
lastY = event.y
if (waitingForBack) {
val dx = abs(event.x - downX)
val dy = abs(event.y - downY)
if (dy > touchSlop && dy >= dx) {
stopWaitingForBack()
beginScrub(event.x, event.y)
} else if (dx > touchSlop) {
// Sideways: Android takes the touch from here on, and with it Back.
stopWaitingForBack()
}
} else if (scrubbing) {
moveScrub(event.x, event.y)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!scrubbing) return false
MotionEvent.ACTION_UP -> {
if (waitingForBack) {
// Down and up without moving: a tap on a letter.
stopWaitingForBack()
beginScrub(event.x, event.y)
}
endScrub()
return true
}
MotionEvent.ACTION_CANCEL -> {
stopWaitingForBack()
endScrub()
return true
}
@@ -415,6 +478,11 @@ class WaveAlphabetView(
return scrubbing
}
private fun stopWaitingForBack() {
waitingForBack = false
removeCallbacks(scrubAfterRest)
}
/** Starts a scrub with the finger at ([x], [y]) in this view's coordinates. */
fun beginScrub(x: Float, y: Float) {
refreshIfChanged()
@@ -643,6 +711,12 @@ class WaveAlphabetView(
/** The tallest a resting letter may be, as a share of the vertical room it has. */
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
/**
* How long a touch on the Back strip may rest before it counts as a scrub. Long
* enough for a Back swipe to have started moving, short enough not to be felt.
*/
const val REST_BEFORE_SCRUB_MS = 130L
const val PRESS_IN_MS = 110L
const val PRESS_OUT_MS = 220L
+21 -1
View File
@@ -47,6 +47,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.flexera.update.CheckProblem
import app.flexera.update.FlexeraRelease
import app.flexera.update.FlexeraUpdater
import app.flexera.update.UpdateFailure
@@ -294,7 +295,7 @@ private fun UpdateStatusRow(state: UpdateState) {
if (release == null) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_check_failed),
subtitle = checkFailureText(state),
onClick = { FlexeraUpdater.check(context) },
)
} else {
@@ -379,6 +380,25 @@ private fun QrCode(text: String, description: String, modifier: Modifier = Modif
}
}
/**
* Says why a check for updates failed and what to do about it, then the error's own words, which
* are what someone looking for the cause will ask for.
*/
@Composable
private fun checkFailureText(state: UpdateState.Failed): String {
val host = FlexeraUpdater.SERVER_HOST
val reason = when (state.problem) {
CheckProblem.NO_PERMISSION -> stringResource(id = R.string.flexera_update_failed_no_permission)
CheckProblem.SERVER_NOT_FOUND -> stringResource(id = R.string.flexera_update_failed_not_found, host)
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
}
val detail = state.detail ?: return reason
return reason + "\n" + stringResource(id = R.string.flexera_update_failed_detail, detail)
}
@Composable
private fun downloadSizeText(context: Context, release: FlexeraRelease): String =
if (release.apkSize > 0) {
@@ -4,11 +4,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.flexera.drawer.FlexeraDrawer
import app.lawnchair.preferences.getAdapter
import app.lawnchair.preferences.preferenceManager
import app.lawnchair.preferences2.preferenceManager2
import app.lawnchair.ui.preferences.LocalIsExpandedScreen
import app.lawnchair.ui.preferences.components.NavigationActionPreference
import app.lawnchair.ui.preferences.components.controls.ListPreference
import app.lawnchair.ui.preferences.components.controls.ListPreferenceEntry
import app.lawnchair.ui.preferences.components.controls.SwitchPreference
import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
@@ -75,6 +78,18 @@ fun FlexeraDrawerSettings(modifier: Modifier = Modifier) {
// No background colour or opacity here: the drawer is transparent with white text, and a
// light panel behind white text would make the list unreadable.
PreferenceGroup(heading = stringResource(id = R.string.style)) {
ListPreference(
adapter = prefs.flexeraDrawerRowSize.getAdapter(),
entries = listOf(
ListPreferenceEntry(FlexeraDrawer.RowSize.COMPACT.key) {
stringResource(id = R.string.flexera_row_size_compact)
},
ListPreferenceEntry(FlexeraDrawer.RowSize.COMFY.key) {
stringResource(id = R.string.flexera_row_size_comfy)
},
),
label = stringResource(id = R.string.flexera_row_size),
)
SwitchPreference(
label = stringResource(id = R.string.pref_all_apps_search_bar_background),
adapter = prefs2.appDrawerSearchBarBackground.getAdapter(),
@@ -24,6 +24,8 @@ import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
/** One published version of Flexera Launcher. */
@@ -56,6 +59,31 @@ enum class UpdateFailure {
WRONG_FILE,
}
/**
* Why a check for updates failed, as far as the phone can tell. The About screen says it in
* words, because "could not reach the server" alone leaves the owner guessing between the
* connection, a VPN, the server and the phone's own settings.
*/
enum class CheckProblem {
/** Android does not let this app use the network (a per-app switch on some systems). */
NO_PERMISSION,
/** The server's name could not be looked up. */
SERVER_NOT_FOUND,
/** The server was found but did not answer in time, or refused the connection. */
NO_ANSWER,
/** The encrypted connection could not be set up. */
NOT_SECURE,
/** The server answered, with an error. */
SERVER_ERROR,
/** The server answered with something that is not a release. */
BAD_ANSWER,
}
/** Where the updater currently stands. The About screen shows one row per state. */
sealed interface UpdateState {
/** Nothing has been checked since the launcher started. */
@@ -72,8 +100,17 @@ sealed interface UpdateState {
data class ReadyToInstall(val release: FlexeraRelease, val file: File) : UpdateState
/** [release] is set when the failure happened after a newer version had been found. */
data class Failed(val failure: UpdateFailure, val release: FlexeraRelease? = null) : UpdateState
/**
* [release] is set when the failure happened after a newer version had been found. For a
* failed check, [problem] says why and [detail] carries the error's own words, for
* whoever has to find the cause.
*/
data class Failed(
val failure: UpdateFailure,
val release: FlexeraRelease? = null,
val problem: CheckProblem? = null,
val detail: String? = null,
) : UpdateState
}
/**
@@ -190,7 +227,7 @@ object FlexeraUpdater {
Log.w(TAG, "Update check failed", error)
// A background check that fails stays quiet: the About screen keeps what it showed
// before, including an update that is already waiting. It tries again in an hour.
mutableState.value = if (automatic) before else UpdateState.Failed(UpdateFailure.CHECK)
mutableState.value = if (automatic) before else failedCheck(error)
return@launch
}
@@ -302,13 +339,41 @@ object FlexeraUpdater {
private fun isBusy(): Boolean =
mutableState.value.let { it is UpdateState.Checking || it is UpdateState.Downloading }
/** The server's name without the scheme, for messages. */
@JvmField
val SERVER_HOST: String = SERVER.substringAfter("://")
private class NoNetworkPermissionException : IOException("This app has no network permission")
private class ServerErrorException(status: Int) : IOException("The server answered HTTP $status")
private class BadAnswerException(message: String) : IOException(message)
/** Turns the error of a failed check into a state that says what kind of failure it was. */
private fun failedCheck(error: Throwable): UpdateState.Failed {
val problem = when (error) {
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
is UnknownHostException -> CheckProblem.SERVER_NOT_FOUND
is SSLException -> CheckProblem.NOT_SECURE
is ServerErrorException -> CheckProblem.SERVER_ERROR
is BadAnswerException, is JSONException -> CheckProblem.BAD_ANSWER
else -> CheckProblem.NO_ANSWER
}
// Android's own error classes keep their names in a release build; this file's are
// renamed by the shrinker, and their messages say everything anyway.
val name = error.javaClass.name.takeIf { it.startsWith("java") || it.startsWith("android") || it.startsWith("org.json") }
?.substringAfterLast('.')
val detail = listOfNotNull(name, error.message).joinToString(": ").take(MAX_DETAIL_LENGTH)
return UpdateState.Failed(UpdateFailure.CHECK, problem = problem, detail = detail.ifEmpty { null })
}
private const val MAX_DETAIL_LENGTH = 200
/** Returns the latest published release, or null when nothing has been published yet. */
private fun fetchLatestRelease(context: Context): FlexeraRelease? {
val connection = open(context, LATEST_RELEASE_URL)
try {
val status = connection.responseCode
if (status == HttpURLConnection.HTTP_NOT_FOUND) return null
if (status != HttpURLConnection.HTTP_OK) throw IOException("Update server answered HTTP $status")
if (status != HttpURLConnection.HTTP_OK) throw ServerErrorException(status)
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
val tag = json.getString("tag_name")
@@ -326,7 +391,7 @@ object FlexeraUpdater {
apkSize = asset.optLong("size", 0L),
)
}
throw IOException("Release $tag has no APK attached")
throw BadAnswerException("Release $tag has no APK attached")
} finally {
connection.disconnect()
}
@@ -373,7 +438,7 @@ object FlexeraUpdater {
}
private fun open(context: Context, url: String): HttpURLConnection {
if (!FlexeraNetwork.hasInternetPermission(context)) throw IOException("No INTERNET permission")
if (!FlexeraNetwork.hasInternetPermission(context)) throw NoNetworkPermissionException()
return (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
+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.6.0
versionName=0.7.0
@@ -224,6 +224,9 @@ class PreferenceManager @Inject constructor(
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
// Flexera Launcher: how large the rows of the drawer list are, "compact" or "comfy" (FlexeraDrawer.RowSize).
val flexeraDrawerRowSize = StringPref("pref_flexeraDrawerRowSize", "compact", recreate)
override fun close() {
TODO("Not yet implemented")
}
+11 -1
View File
@@ -195,7 +195,8 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
private boolean mLayoutHorizontal;
private final boolean mIsRtl;
private final int mIconSize;
// Flexera Launcher: not final, see setIconSizeOverride.
private int mIconSize;
@ViewDebug.ExportedProperty(category = "launcher")
private boolean mHideBadge = false;
@@ -1563,6 +1564,15 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
return mIconSize;
}
/**
* Flexera Launcher: the rows of the drawer list come in more than one size, chosen by
* the owner at run time, which the layout attribute cannot follow. Call before an icon
* is applied.
*/
public void setIconSizeOverride(int iconSize) {
mIconSize = iconSize;
}
public boolean isDisplaySearchResult() {
return mDisplay == DISPLAY_SEARCH_RESULT
|| mDisplay == DISPLAY_SEARCH_RESULT_SMALL