feat : wallpaper pop-up carousel (#5143)

This commit is contained in:
John Andrew Camu
2025-01-08 19:04:28 +08:00
committed by GitHub
parent a463fe905a
commit 3a1e1389a2
17 changed files with 586 additions and 4 deletions

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<view xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:id="@+id/popup_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
class="app.lawnchair.ui.popup.LawnchairPopupDialog$LawnchairOptionsPopUp">
<LinearLayout
android:orientation="vertical"
android:id="@+id/wallpaper_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp">
<app.lawnchair.ui.popup.WallpaperCarouselView
android:orientation="horizontal"
android:id="@+id/wallpaper_carousel"
android:layout_width="match_parent"
android:layout_height="@dimen/wallpaper_carousel_height"
android:layout_margin="@dimen/system_shortcut_margin_start"/>
</LinearLayout>
</view>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<com.android.launcher3.shortcuts.DeepShortcutView xmlns:android="http://schemas.android.com/apk/res/android"
android:theme="@style/PopupItem"
android:layout_width="match_parent"
android:layout_height="@dimen/bg_popup_item_height"
android:elevation="2dp">
<include layout="@layout/system_shortcut_content"/>
</com.android.launcher3.shortcuts.DeepShortcutView>

View File

@@ -105,4 +105,6 @@
<dimen name="search_row_files_preview_height">64dp</dimen>
<dimen name="search_row_preview_radius">18dp</dimen>
<dimen name="wallpaper_carousel_height">120dp</dimen>
</resources>

View File

@@ -47,6 +47,7 @@ import app.lawnchair.theme.ThemeProvider
import app.lawnchair.ui.popup.LawnchairShortcut
import app.lawnchair.util.getThemedIconPacksInstalled
import app.lawnchair.util.unsafeLazy
import app.lawnchair.wallpaper.service.WallpaperDatabase
import com.android.launcher3.AbstractFloatingView
import com.android.launcher3.BaseActivity
import com.android.launcher3.BubbleTextView
@@ -227,6 +228,8 @@ class LawnchairLauncher : QuickstepLauncher() {
showQuickstepWarningIfNecessary()
reloadIconsIfNeeded()
WallpaperDatabase.INSTANCE.get(this).checkpointSync()
}
override fun collectStateHandlers(out: MutableList<StateHandler<LauncherState>>) {

View File

@@ -0,0 +1,16 @@
package app.lawnchair.ui.popup
import android.content.Context
import android.util.AttributeSet
import android.view.View
import com.android.launcher3.R
import com.android.launcher3.views.ActivityContext
import com.android.launcher3.views.OptionsPopupView
class LawnchairPopupDialog {
class LawnchairOptionsPopUp<T>(context: T, attributeSet: AttributeSet) : OptionsPopupView<T>(context, attributeSet)
where T : Context, T : ActivityContext {
override fun isShortcutOrWrapper(view: View): Boolean = view.id == R.id.wallpaper_container || super.isShortcutOrWrapper(view)
}
}

View File

@@ -0,0 +1,227 @@
package app.lawnchair.ui.popup
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.WallpaperManager
import android.content.Context
import android.graphics.BitmapFactory
import android.util.AttributeSet
import android.util.Log
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import app.lawnchair.LawnchairLauncher
import app.lawnchair.views.component.IconFrame
import app.lawnchair.wallpaper.model.WallpaperViewModel
import app.lawnchair.wallpaper.model.WallpaperViewModelFactory
import app.lawnchair.wallpaper.service.Wallpaper
import com.android.launcher3.R
import com.android.launcher3.util.Themes
import com.android.launcher3.views.ActivityContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class WallpaperCarouselView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
private val viewModel: WallpaperViewModel
private val deviceProfile = ActivityContext.lookupContext<LawnchairLauncher>(context).deviceProfile
private var currentItemIndex = 0
private val iconFrame = IconFrame(context).apply {
setIcon(R.drawable.ic_tick)
setBackgroundWithRadius(
bgColor = Themes.getColorAccent(context),
cornerRadius = 100F,
)
}
private val loadingView: ProgressBar = ProgressBar(context).apply {
isIndeterminate = true
visibility = VISIBLE
}
init {
orientation = HORIZONTAL
addView(loadingView)
val factory = WallpaperViewModelFactory(context)
viewModel = ViewModelProvider(context as ViewModelStoreOwner, factory)[WallpaperViewModel::class.java]
observeWallpapers()
}
private fun observeWallpapers() {
viewModel.wallpapers.observe(context as LifecycleOwner) { wallpapers ->
if (wallpapers.isEmpty()) {
visibility = GONE
loadingView.visibility = GONE
} else {
try {
visibility = VISIBLE
displayWallpapers(wallpapers)
} catch (e: Exception) {
Log.e("WallpaperCarouselView", "Error displaying wallpapers: ${e.message}")
}
}
}
}
@SuppressLint("ClickableViewAccessibility")
private fun displayWallpapers(wallpapers: List<Wallpaper>) {
removeAllViews()
val totalWidth = width.takeIf { it > 0 } ?: (deviceProfile.widthPx * 0.8).toInt()
val firstItemWidth = totalWidth * 0.5
val remainingWidth = totalWidth - firstItemWidth
val marginBetweenItems = totalWidth * 0.02
val itemWidth = (remainingWidth - (marginBetweenItems * (wallpapers.size - 1))) / (wallpapers.size - 1)
wallpapers.forEachIndexed { index, wallpaper ->
val cardView = CardView(context).apply {
radius = Themes.getDialogCornerRadius(context) / 2
layoutParams = LayoutParams(
when (index) {
currentItemIndex -> firstItemWidth.toInt()
else -> itemWidth.toInt()
},
LayoutParams.MATCH_PARENT,
).apply {
setMargins(
if (index > 0) marginBetweenItems.toInt() else 0,
0,
0,
0,
)
}
setOnTouchListener { _, _ ->
if (index != currentItemIndex) {
animateWidthTransition(index, firstItemWidth, itemWidth)
} else {
setWallpaper(wallpaper)
}
true
}
}
val placeholderImageView = ImageView(context).apply {
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_deepshortcut_placeholder))
scaleType = ImageView.ScaleType.CENTER_CROP
}
cardView.addView(placeholderImageView)
addView(cardView)
CoroutineScope(Dispatchers.IO).launch {
val bitmap = BitmapFactory.decodeFile(wallpaper.imagePath)
withContext(Dispatchers.Main) {
(cardView.getChildAt(0) as? ImageView)?.apply {
setImageBitmap(bitmap)
}
if (index == currentItemIndex) {
addIconFrameToCenter(cardView)
}
}
}
}
loadingView.visibility = GONE
}
private fun setWallpaper(wallpaper: Wallpaper) {
val loadingSpinner = ProgressBar(context).apply {
isIndeterminate = true
layoutParams = FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
gravity = Gravity.CENTER
}
}
val currentCardView = getChildAt(currentItemIndex) as CardView
currentCardView.removeView(iconFrame)
currentCardView.addView(loadingSpinner)
CoroutineScope(Dispatchers.IO).launch {
try {
val wallpaperManager = WallpaperManager.getInstance(context)
val bitmap = BitmapFactory.decodeFile(wallpaper.imagePath)
wallpaperManager.setBitmap(bitmap, null, true, WallpaperManager.FLAG_SYSTEM)
withContext(Dispatchers.Main) {
currentCardView.removeView(loadingSpinner)
addIconFrameToCenter(currentCardView)
}
} catch (e: Exception) {
Log.e("WallpaperCarouselView", "Failed to set wallpaper: ${e.message}")
withContext(Dispatchers.Main) {
currentCardView.removeView(loadingSpinner)
addIconFrameToCenter(currentCardView)
}
}
}
}
private fun addIconFrameToCenter(cardView: CardView) {
if (iconFrame.parent != null) {
(iconFrame.parent as ViewGroup).removeView(iconFrame)
}
val params = FrameLayout.LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
).apply {
gravity = Gravity.CENTER
}
cardView.addView(iconFrame, params)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val valWidth = (deviceProfile.widthPx * 0.8).toInt()
val width = MeasureSpec.makeMeasureSpec(valWidth, MeasureSpec.EXACTLY)
super.onMeasure(width, heightMeasureSpec)
}
private fun animateWidthTransition(
newIndex: Int,
firstItemWidth: Double,
itemWidth: Double,
) {
currentItemIndex = newIndex
for (i in 0 until childCount) {
val cardView = getChildAt(i) as? CardView ?: continue
val targetWidth = if (i == currentItemIndex) firstItemWidth.toInt() else itemWidth.toInt()
if (cardView.layoutParams.width != targetWidth) {
val animator = ValueAnimator.ofInt(cardView.layoutParams.width, targetWidth).apply {
duration = 300L
addUpdateListener { animation ->
val animatedValue = animation.animatedValue as Int
cardView.layoutParams = cardView.layoutParams.apply { width = animatedValue }
cardView.requestLayout()
}
}
animator.start()
}
if (i == currentItemIndex) {
addIconFrameToCenter(cardView)
}
}
}
}

View File

@@ -53,6 +53,7 @@ import com.android.launcher3.util.Executors.MAIN_EXECUTOR
import com.android.launcher3.util.Themes
import com.android.systemui.shared.system.QuickStepContract
import com.patrykmichalik.opto.core.firstBlocking
import java.io.ByteArrayOutputStream
import java.util.concurrent.Callable
import java.util.concurrent.ExecutionException
import kotlin.math.max
@@ -254,6 +255,12 @@ fun Size.scaleDownTo(maxSize: Int): Size {
}
}
fun bitmapToByteArray(bitmap: Bitmap): ByteArray {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
return stream.toByteArray()
}
fun Context.isDefaultLauncher(): Boolean = getDefaultLauncherPackageName() == packageName
fun Context.getDefaultLauncherPackageName(): String? = runCatching { getDefaultResolveInfo()?.activityInfo?.packageName }.getOrNull()

View File

@@ -0,0 +1,73 @@
package app.lawnchair.views.component
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import com.android.launcher3.R
import com.android.launcher3.util.Themes
class IconFrame @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
private val imageView: ImageView
init {
layoutParams = LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
)
imageView = ImageView(context).apply {
layoutParams = LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
)
setPadding(12.dpToPx(context), 12.dpToPx(context), 12.dpToPx(context), 12.dpToPx(context))
}
addView(imageView)
setBackgroundWithRadius(
bgColor = ContextCompat.getColor(context, R.color.accent_primary_device_default),
cornerRadius = Themes.getDialogCornerRadius(context),
)
}
/**
* Convert dp to pixels for consistent padding across devices.
*/
private fun Int.dpToPx(context: Context): Int {
val density = context.resources.displayMetrics.density
return (this * density).toInt()
}
/**
* Set the vector drawable for the ImageView.
*
* @param drawableRes The resource ID of the vector drawable.
*/
fun setIcon(@DrawableRes drawableRes: Int) {
imageView.setImageResource(drawableRes)
}
/**
* Set the background color and corner radius of the FrameLayout.
*
* @param bgColor The background color.
* @param cornerRadius The corner radius in pixels.
*/
fun setBackgroundWithRadius(bgColor: Int, cornerRadius: Float) {
val backgroundDrawable = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
setColor(bgColor)
this.cornerRadius = cornerRadius
}
background = backgroundDrawable
}
}

View File

@@ -5,7 +5,11 @@ import android.content.Context
import app.lawnchair.util.MainThreadInitializedObject
import app.lawnchair.util.requireSystemService
import app.lawnchair.wallpaper.WallpaperColorsCompat.Companion.HINT_SUPPORTS_DARK_THEME
import app.lawnchair.wallpaper.service.WallpaperService
import com.android.launcher3.Utilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
sealed class WallpaperManagerCompat(val context: Context) {
@@ -29,6 +33,10 @@ sealed class WallpaperManagerCompat(val context: Context) {
listeners.toTypedArray().forEach {
it.onColorsChanged()
}
CoroutineScope(Dispatchers.IO).launch {
WallpaperService(context).saveWallpaper(wallpaperManager)
}
}
interface OnColorsChangedListener {

View File

@@ -0,0 +1,28 @@
package app.lawnchair.wallpaper.model
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.lawnchair.wallpaper.service.Wallpaper
import app.lawnchair.wallpaper.service.WallpaperDatabase
import kotlinx.coroutines.launch
class WallpaperViewModel(context: Context) : ViewModel() {
private val dao = WallpaperDatabase.INSTANCE.get(context).wallpaperDao()
private val _wallpapers = MutableLiveData<List<Wallpaper>>()
val wallpapers: LiveData<List<Wallpaper>> = _wallpapers
init {
loadTopWallpapers()
}
private fun loadTopWallpapers() {
viewModelScope.launch {
val topWallpapers = dao.getTopWallpapers()
_wallpapers.postValue(topWallpapers)
}
}
}

View File

@@ -0,0 +1,15 @@
package app.lawnchair.wallpaper.model
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
class WallpaperViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(WallpaperViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return WallpaperViewModel(context) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@@ -0,0 +1,12 @@
package app.lawnchair.wallpaper.service
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "wallpapers")
data class Wallpaper(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val imagePath: String,
val rank: Int,
val timestamp: Long,
)

View File

@@ -0,0 +1,25 @@
package app.lawnchair.wallpaper.service
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
@Dao
interface WallpaperDao {
@Insert
suspend fun insert(wallpaper: Wallpaper)
@Query("SELECT * FROM wallpapers ORDER BY timestamp DESC LIMIT 4")
suspend fun getTopWallpapers(): List<Wallpaper>
@Query("UPDATE wallpapers SET rank = rank + 1 WHERE rank >= :rank")
suspend fun updateRank(rank: Int)
@Query("DELETE FROM wallpapers WHERE id = :id")
suspend fun deleteWallpaper(id: Long)
@RawQuery
suspend fun checkpoint(supportSQLiteQuery: SupportSQLiteQuery): Int
}

View File

@@ -0,0 +1,34 @@
package app.lawnchair.wallpaper.service
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SimpleSQLiteQuery
import app.lawnchair.util.MainThreadInitializedObject
import kotlinx.coroutines.runBlocking
@Database(entities = [Wallpaper::class], version = 1, exportSchema = false)
abstract class WallpaperDatabase : RoomDatabase() {
abstract fun wallpaperDao(): WallpaperDao
private suspend fun checkpoint() {
wallpaperDao().checkpoint(SimpleSQLiteQuery("pragma wal_checkpoint(full)"))
}
fun checkpointSync() {
runBlocking {
checkpoint()
}
}
companion object {
val INSTANCE = MainThreadInitializedObject { context ->
Room.databaseBuilder(
context,
WallpaperDatabase::class.java,
"wallpaper_database",
).build()
}
}
}

View File

@@ -0,0 +1,98 @@
package app.lawnchair.wallpaper.service
import android.app.WallpaperManager
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.util.Log
import androidx.core.graphics.drawable.toBitmap
import app.lawnchair.util.bitmapToByteArray
import com.android.launcher3.util.MainThreadInitializedObject
import com.android.launcher3.util.SafeCloseable
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.runBlocking
class WallpaperService(val context: Context) : SafeCloseable {
private val dao = WallpaperDatabase.INSTANCE.get(context).wallpaperDao()
suspend fun saveWallpaper(wallpaperManager: WallpaperManager) {
try {
val wallpaperDrawable = wallpaperManager.drawable
val currentBitmap = (wallpaperDrawable as BitmapDrawable).toBitmap()
val byteArray = bitmapToByteArray(currentBitmap)
saveWallpaper(byteArray)
} catch (e: Exception) {
Log.e("WallpaperChange", "Error detecting wallpaper change: ${e.message}")
}
}
private suspend fun saveWallpaper(imageData: ByteArray) {
val timestamp = System.currentTimeMillis()
val topWallpapers = dao.getTopWallpapers()
val imagePath = saveImageToAppStorage(imageData)
if (topWallpapers.size < 4) {
val wallpaper = Wallpaper(imagePath = imagePath, rank = topWallpapers.size, timestamp = timestamp)
dao.insert(wallpaper)
} else {
val lowestRankedWallpaper = topWallpapers.minByOrNull { it.timestamp }
if (lowestRankedWallpaper != null) {
dao.deleteWallpaper(lowestRankedWallpaper.id)
deleteWallpaperFile(lowestRankedWallpaper.imagePath)
}
for (wallpaper in topWallpapers) {
if (wallpaper.rank >= (lowestRankedWallpaper?.rank ?: 0)) {
dao.updateRank(wallpaper.rank)
}
}
val wallpaper = Wallpaper(imagePath = imagePath, rank = 0, timestamp = timestamp)
dao.insert(wallpaper)
}
}
fun getTopWallpapers(): List<Wallpaper> = runBlocking {
val wallpapers = dao.getTopWallpapers()
wallpapers.ifEmpty { emptyList() }
}
private fun deleteWallpaperFile(imagePath: String) {
val file = File(imagePath)
if (file.exists()) {
file.delete()
}
}
private fun saveImageToAppStorage(imageData: ByteArray): String {
val storageDir = File(context.filesDir, "wallpapers")
if (!storageDir.exists()) {
storageDir.mkdirs()
}
val imageHash = imageData.hashCode().toString()
val imageFile = File(storageDir, "wallpaper_$imageHash.jpg")
if (!imageFile.exists()) {
FileOutputStream(imageFile).use { fos ->
fos.write(imageData)
}
}
return imageFile.absolutePath
}
override fun close() {
TODO("Not yet implemented")
}
companion object {
@JvmField
val INSTANCE = MainThreadInitializedObject(::WallpaperService)
}
}

View File

@@ -150,8 +150,6 @@ public abstract class ArrowPopup<T extends Context & ActivityContext>
// Initialize arrow view
final Resources resources = getResources();
mArrowColor = getColorStateList(getContext(), R.color.popup_color_background)
.getDefaultColor();
mChildContainerMargin = resources.getDimensionPixelSize(R.dimen.popup_margin);
mArrowWidth = resources.getDimensionPixelSize(R.dimen.popup_arrow_width);
mArrowHeight = resources.getDimensionPixelSize(R.dimen.popup_arrow_height);

View File

@@ -56,6 +56,7 @@ import java.util.List;
import app.lawnchair.preferences2.PreferenceManager2;
import app.lawnchair.ui.popup.LauncherOptionsPopup;
import app.lawnchair.wallpaper.service.WallpaperService;
/**
* Popup shown on long pressing an empty space in launcher
@@ -171,13 +172,19 @@ public class OptionsPopupView<T extends Context & ActivityContext> extends Arrow
if (activityContext == null) {
return null;
}
final Context context = activityContext.getDragLayer().mActivity;
final boolean isEmpty = WallpaperService.INSTANCE.get(context).getTopWallpapers().isEmpty();
var layout = isEmpty ? R.layout.longpress_options_menu : R.layout.wallpaper_options_popup;
OptionsPopupView<T> popup = (OptionsPopupView<T>) activityContext.getLayoutInflater()
.inflate(R.layout.longpress_options_menu, activityContext.getDragLayer(), false);
.inflate(layout, activityContext.getDragLayer(), false);
popup.mTargetRect = targetRect;
popup.setShouldAddArrow(shouldAddArrow);
for (OptionItem item : items) {
DeepShortcutView view = popup.inflateAndAdd(R.layout.system_shortcut, popup);
var deepLayout = isEmpty ? R.layout.system_shortcut : R.layout.wallpaper_options_popup_item;
DeepShortcutView view = popup.inflateAndAdd(deepLayout, popup);
if (width > 0) {
view.getLayoutParams().width = width;
}