mirror of
https://github.com/alesimula/Murine-launcher.git
synced 2026-10-28 09:43:42 +00:00
THEMING - ICON-SHAPE - allow importing custom icon shapes
This commit is contained in:
1 parent
d12a7ea4e9
commit
d56409e7b3
13 files changed
+207
-9
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user