THEMING - ICON-SHAPE - allow importing custom icon shapes

This commit is contained in:
Alex Sim committed 2026-10-12 17:01:29 +02:00
1 parent d12a7ea4e9
commit d56409e7b3
13 files changed
+207 -9

No files matched your search

+16
View File
@@ -192,6 +192,22 @@
</intent-filter>
</activity>
<!-- A shape from the website's icon shape editor (see CustomIconShape) -->
<activity
android:name="app.murinelauncher.settings.CustomIconShape$ImportActivity"
android:theme="@android:style/Theme.NoDisplay"
android:taskAffinity=""
android:excludeFromRecents="true"
android:noHistory="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="murine-home" android:host="icon-shape" />
</intent-filter>
</activity>
<provider
android:name="com.android.launcher3.testing.TestInformationProvider"
android:authorities="${applicationId}.TestInfo"
+3 -3
View File
@@ -8,14 +8,14 @@
# both accept several names separated by ', ' and are shown together,
# in that order. Omit both if nobody has reviewed it
total: 148
total: 149
languages:
- code: en
name: English
native: English
status: reviewed
strings: 148
strings: 149
by_git: alesimula
note: Source language
@@ -23,7 +23,7 @@ languages:
name: Italian
native: Italiano
status: reviewed
strings: 148
strings: 149
by_git: alesimula
note: Dev's language
+1
View File
@@ -42,6 +42,7 @@
<string name="pref_label_grid_height">Altezza griglia</string>
<string name="pref_label_icon_size">Dimensione icone (%)</string>
<string name="pref_label_icon_label_size">Dimensione etichette (%)</string>
<string name="pref_label_custom">Personalizzato</string>
<string name="pref_label_visibility_title">Visibilità etichette</string>
<string name="label_visibility_never">Mai</string>
+1
View File
@@ -42,6 +42,7 @@
<string name="pref_label_grid_height">Grid height</string>
<string name="pref_label_icon_size">Icon size (%)</string>
<string name="pref_label_icon_label_size">Label size (%)</string>
<string name="pref_label_custom">Custom</string>
<string name="pref_label_visibility_title">Labels visibility</string>
<string name="label_visibility_never">Never</string>
@@ -22,8 +22,10 @@ object IconShapeDrawables {
fun getShapePreviewDrawable(context: Context, shape: ShapesProvider.IconShape, sizeDp: Int = 0): Drawable {
val color = ContextCompat.getColor(context, R.color.white)
val sizePx = if (sizeDp > 0) (sizeDp * context.resources.displayMetrics.density + 0.5f).toInt() else 0
if (shape.pathString.isNotBlank()) try {
return ShapePreviewDrawable(shape.pathString, color, sizePx)
val path = if (shape == ShapesProvider.IconShape.CUSTOM)
com.android.launcher3.graphics.ThemeManager.PREF_CUSTOM_ICON_SHAPE.get(context) else shape.pathString
if (path.isNotBlank()) try {
return ShapePreviewDrawable(path, color, sizePx)
} catch (_: Exception) {}
return SystemShapePreviewDrawable(color, sizePx)
}
@@ -0,0 +1,76 @@
package app.murinelauncher.settings
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import androidx.core.graphics.PathParser
import com.android.launcher3.LauncherPrefs
import com.android.launcher3.graphics.ThemeManager
import com.android.launcher3.settings.SettingsActivity
import com.android.launcher3.shapes.ShapesProvider
import java.io.ByteArrayOutputStream
import java.util.zip.Inflater
import java.util.zip.InflaterInputStream
/**
* The Custom icon shape: made on the website's editor, sent back with murine-home://icon-shape?shape=...
*/
object CustomIconShape {
private const val EDITOR_URL = "https://www.murinelauncher.app/icon-shapes/"
private const val MAX_PATH = 200_000 // characters, far more than the website's shapes take
fun openEditor(context: Context) =
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(EDITOR_URL)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
/**
* The website's import link: a path that checks out is saved and picked as the icon shape, then Settings
* reopens (replacing whatever it had open) on Icons, with Icon shape highlighted.
*/
private fun importShape(activity: Activity) {
activity.intent?.data?.getQueryParameter("shape")?.let(::unpack)?.let { path ->
LauncherPrefs.get(activity).put(
ThemeManager.PREF_CUSTOM_ICON_SHAPE to path,
ThemeManager.PREF_ICON_SHAPE to ShapesProvider.IconShape.CUSTOM)
}
activity.startActivities(arrayOf(
Intent(activity, SettingsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
Intent(activity, SettingsActivity::class.java)
.putExtra(SettingsActivity.EXTRA_FRAGMENT, SettingsIconsFragment::class.java.name)
.putExtra(SettingsActivity.EXTRA_FRAGMENT_HIGHLIGHT_KEY, SettingsIconsFragment.ICON_SHAPE_KEY)))
}
/**
* Receives the import link (see AndroidManifest-common.xml) in a task of its own: sent to SettingsActivity,
* a Settings task already open would just come to the front, and the link would never arrive.
*/
class ImportActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
importShape(this)
finish()
}
}
/**
* The link's path (raw deflate, base64url, in the 0-100 box), or null unless it inflates to a sane size and
* parses: anyone can send this link, and a path that doesn't parse would break icon loading.
*/
private fun unpack(data: String): String? = try {
// The extra 0 byte: raw ("nowrap") inflating may need one past the end of the data
val bytes = Base64.decode(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + 0.toByte()
val out = ByteArrayOutputStream()
InflaterInputStream(bytes.inputStream(), Inflater(true)).use { input ->
val buf = ByteArray(8192)
var n = 0
while (out.size() <= MAX_PATH && input.read(buf).also { n = it } >= 0) out.write(buf, 0, n)
}
out.takeIf { it.size() <= MAX_PATH }?.toString("UTF-8")?.trim()
?.takeIf { it.startsWith("M") }?.also { PathParser.createPathFromPathData(it) }
} catch (e: Exception) {
null
}
}
@@ -40,6 +40,10 @@ public final class SettingsIconsFragment: AbstractSettingsFragment() {
setDefaultValue(ThemeManager.PREF_ICON_SHAPE.defaultValue)
setTextProvider { ctx, shape -> ctx.getString(shape.title) }
setIconProvider { ctx, shape -> IconShapeDrawables.getShapePreviewDrawable(ctx, shape) }
// Custom: the button on its right opens the website's editor
setExtraActionProvider { _, shape ->
if (shape == ShapesProvider.IconShape.CUSTOM) android.view.View.OnClickListener { CustomIconShape.openEditor(it.context) } else null
}
}
return true
}
@@ -30,6 +30,7 @@ import com.android.launcher3.settings.SettingsActivity;
import com.android.launcher3.util.DisplayController;
import com.android.launcher3.util.SettingsCache;
import com.android.settingslib.widget.SettingsBasePreferenceFragment;
import com.android.settingslib.widget.SettingsThemeHelper;
import java.util.Collections;
import java.util.HashSet;
@@ -165,6 +166,15 @@ public abstract class AbstractSettingsFragment extends SettingsBasePreferenceFra
return null;
}
@NonNull
@Override
public RecyclerView.Adapter<?> onCreateAdapter(@NonNull PreferenceScreen preferenceScreen) {
// SettingsLib's cards, flashing like in Android Settings when a preference is pointed at
return SettingsThemeHelper.isExpressiveTheme(requireContext())
? new HighlightablePreferenceAdapter(preferenceScreen)
: super.onCreateAdapter(preferenceScreen);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
@@ -277,7 +287,7 @@ public abstract class AbstractSettingsFragment extends SettingsBasePreferenceFra
super.onResume();
if (isAdded() && !mPreferenceHighlighted) {
PreferenceHighlighter highlighter = createHighlighter();
Runnable highlighter = createHighlighter();
if (highlighter != null) {
getView().postDelayed(highlighter, DELAY_HIGHLIGHT_DURATION_MILLIS);
mPreferenceHighlighted = true;
@@ -328,7 +338,7 @@ public abstract class AbstractSettingsFragment extends SettingsBasePreferenceFra
}
}
private PreferenceHighlighter createHighlighter() {
private Runnable createHighlighter() {
if (TextUtils.isEmpty(mHighLightKey)) {
return null;
}
@@ -339,6 +349,10 @@ public abstract class AbstractSettingsFragment extends SettingsBasePreferenceFra
}
RecyclerView list = getListView();
if (list.getAdapter() instanceof HighlightablePreferenceAdapter adapter) {
String key = mHighLightKey;
return adapter.getPreferenceAdapterPosition(key) >= 0 ? () -> adapter.highlight(list, key) : null;
}
PreferenceGroup.PreferencePositionCallback callback = (PreferenceGroup.PreferencePositionCallback) list.getAdapter();
int position = callback.getPreferenceAdapterPosition(mHighLightKey);
return position >= 0 ? new PreferenceHighlighter(
@@ -0,0 +1,68 @@
package app.murinelauncher.settings.common
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.graphics.drawable.LayerDrawable
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceGroup
import androidx.preference.PreferenceViewHolder
import androidx.recyclerview.widget.RecyclerView
import com.android.settingslib.widget.SettingsPreferenceGroupAdapter
/**
* SettingsLib's card adapter, plus the flash Android Settings uses to point at a setting
*/
class HighlightablePreferenceAdapter(group: PreferenceGroup) : SettingsPreferenceGroupAdapter(group) {
private var highlightPosition = RecyclerView.NO_POSITION
/** Scrolls [list] to the preference with [key] and flashes it; false if it isn't on this screen. */
fun highlight(list: RecyclerView, key: String): Boolean {
val position = getPreferenceAdapterPosition(key)
if (position < 0) return false
list.itemAnimator = null // as in Settings: a change animation would fade the rebound card
highlightPosition = position
list.smoothScrollToPosition(position)
notifyItemChanged(position) // flashes when bound, i.e. once it scrolls into view
return true
}
override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
if (position != highlightPosition) return
highlightPosition = RecyclerView.NO_POSITION
val view = holder.itemView
val res = getRoundCornerDrawableRes(position, isSelected = false, isHighlighted = true)
val flash = (if (res != 0) ContextCompat.getDrawable(view.context, res) else null) ?: return
val card = view.background?.mutate() // its alpha changes below, and drawable state is shared otherwise
flash.alpha = 0
// Stacked, not nested: by default each layer is inset by the padding of the ones below, and the card
// drawables carry padding for the row's content, which would shrink the flash inside the card
view.background = LayerDrawable(listOfNotNull(card, flash).toTypedArray())
.apply { paddingMode = LayerDrawable.PADDING_MODE_STACK }
holder.setIsRecyclable(false) // the card keeps its flash layer until it's put back below
val fade = { from: Int, to: Int -> ValueAnimator.ofInt(from, to).apply {
addUpdateListener {
val alpha = it.animatedValue as Int
flash.alpha = alpha
card?.alpha = 255 - alpha
}
} }
AnimatorSet().apply {
// Same timing as PreferenceHighlighter: three pulses, held a while, then a slow fade out
playSequentially(
fade(0, 255).apply { duration = 200; repeatMode = ValueAnimator.REVERSE; repeatCount = 4 },
fade(255, 0).apply { duration = 500; startDelay = 15_000 })
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
card?.alpha = 255
view.background = card
holder.setIsRecyclable(true)
}
})
start()
}
}
}
@@ -38,6 +38,7 @@ open class RadioGroupBottomSheet : BottomSheetDialogFragment() {
private var sheetIconTint: Int? = null
private var isVisibleProvider: ((Int) -> Boolean)? = null
private var isEnabledProvider: ((Int) -> Boolean)? = null
private var extraActionProvider: ((Int) -> View.OnClickListener?)? = null
fun configure(
title: CharSequence?,
@@ -49,6 +50,7 @@ open class RadioGroupBottomSheet : BottomSheetDialogFragment() {
iconTint: Int? = null,
isVisibleProvider: ((Int) -> Boolean)? = null,
isEnabledProvider: ((Int) -> Boolean)? = null,
extraActionProvider: ((Int) -> View.OnClickListener?)? = null,
listener: OnItemSelectedListener
) {
this.sheetTitle = title
@@ -60,6 +62,7 @@ open class RadioGroupBottomSheet : BottomSheetDialogFragment() {
this.sheetIconTint = iconTint
this.isVisibleProvider = isVisibleProvider
this.isEnabledProvider = isEnabledProvider
this.extraActionProvider = extraActionProvider
this.listener = listener
}
@@ -102,6 +105,7 @@ open class RadioGroupBottomSheet : BottomSheetDialogFragment() {
val pref = createEntryPref(ctx, i, textProv(i), icon,
i == sheet.currentIndex, iconPosition)
if (sheet.isEnabledProvider?.invoke(i) == false) pref.isEnabled = false
sheet.extraActionProvider?.invoke(i)?.let(pref::setExtraWidgetOnClickListener)
screen.addPreference(pref)
}
preferenceScreen = screen
@@ -43,6 +43,7 @@ class RadioGroupPreference @JvmOverloads constructor(
internal var summaryProviderIdx: ((Context, Int) -> CharSequence?)? = null
internal var isVisibleProviderIdx: ((Context, Int) -> Boolean)? = null
internal var isEnabledProviderIdx: ((Context, Int) -> Boolean)? = null
internal var extraActionProviderIdx: ((Context, Int) -> android.view.View.OnClickListener?)? = null
private var keyEntries: Array<String>? = null
private var fragmentManager: FragmentManager? = null
@@ -290,6 +291,7 @@ class RadioGroupPreference @JvmOverloads constructor(
iconTint = if (tintSheetIcons) resolveIconTint() else null,
isVisibleProvider = isVisibleProviderIdx?.let { p -> { i -> p(ctx, i) } },
isEnabledProvider = isEnabledProviderIdx?.let { p -> { i -> p(ctx, i) } },
extraActionProvider = extraActionProviderIdx?.let { p -> { i -> p(ctx, i) } },
listener = RadioGroupBottomSheet.OnItemSelectedListener { index ->
if (!callChangeListener(index)) return@OnItemSelectedListener
persistValue(index)
@@ -340,6 +342,11 @@ class RadioGroupPreference @JvmOverloads constructor(
preference.isEnabledProviderIdx = provider?.let { p -> { ctx, i -> p(ctx, entries[i]) } }
}
/** A button on the right of an entry (null for none), e.g. to edit it */
fun setExtraActionProvider(provider: ((Context, T) -> android.view.View.OnClickListener?)?) {
preference.extraActionProviderIdx = provider?.let { p -> { ctx, i -> p(ctx, entries[i]) } }
}
fun setOnPreferenceChangeListener(listener: ((T?) -> Boolean)?) {
preference.onPreferenceChangeListener = listener?.let { l -> OnPreferenceChangeListener {
_, i -> l((i as? Int)?.let { entries[i] }) }
@@ -77,7 +77,7 @@ constructor(
val receiver = SimpleBroadcastReceiver(context, MAIN_EXECUTOR) { verifyIconState() }
receiver.registerPkgActions("android", ACTION_OVERLAY_CHANGED)
val keys = (iconControllerFactory.prefKeys + PREF_ICON_SHAPE)
val keys = (iconControllerFactory.prefKeys + PREF_ICON_SHAPE + PREF_CUSTOM_ICON_SHAPE)
val keysArray = keys.toTypedArray()
val prefKeySet = keys.map { it.sharedPrefKey }
@@ -105,8 +105,9 @@ constructor(
private fun parseIconState(oldState: IconState?): IconState {
val shapeModel = prefs.get(PREF_ICON_SHAPE)
val shapePath = if (shapeModel == ShapesProvider.IconShape.CUSTOM) prefs.get(PREF_CUSTOM_ICON_SHAPE) else shapeModel.pathString
val iconMask = when {
shapeModel.pathString.isNotBlank() -> shapeModel.pathString
shapePath.isNotBlank() -> shapePath
CONFIG_ICON_MASK_RES_ID == Resources.ID_NULL -> ""
else -> context.resources.getString(CONFIG_ICON_MASK_RES_ID)
}
@@ -170,6 +171,8 @@ constructor(
const val KEY_THEMED_ICONS = "themed_icons"
@JvmField val THEMED_ICONS = backedUpItem(KEY_THEMED_ICONS, false, EncryptionType.ENCRYPTED)
@JvmField val PREF_ICON_SHAPE = backedUpItem(SettingsIconsFragment.ICON_SHAPE_KEY, ShapesProvider.IconShape.IOS, EncryptionType.ENCRYPTED)
/** Path of the CUSTOM shape, kept when another shape is picked; only written once validated (see [app.murinelauncher.settings.CustomIconShape]) */
@JvmField val PREF_CUSTOM_ICON_SHAPE = backedUpItem("pref_icon_shape_custom", "", EncryptionType.ENCRYPTED)
private const val ACTION_OVERLAY_CHANGED = "android.intent.action.OVERLAY_CHANGED"
private val CONFIG_ICON_MASK_RES_ID: Int =
@@ -26,6 +26,8 @@ object ShapesProvider {
override val folderPathString: String = pathString,
override val iconScale: Float = 1.0f
): IconShapeModel {
// The path is the last one imported from the website: ThemeManager.PREF_CUSTOM_ICON_SHAPE
CUSTOM(title = R.string.pref_label_custom, pathString = ""),
SYSTEM(title = R.string.icon_shape_system, pathString = ""),
CIRCLE(title = R.string.icon_shape_circle, pathString = CIRCLE_PATH),
RICEBALLS(title = R.string.icon_shape_riceballs, pathString = RICEBALLS_PATH),