ソースを参照

Glance widget for Updates (#7581)

* glance

* glance2
Ivan Iskandar 2 年 前
コミット
29e1976b90

+ 1 - 0
app/build.gradle.kts

@@ -189,6 +189,7 @@ dependencies {
     implementation(androidx.splashscreen)
     implementation(androidx.recyclerview)
     implementation(androidx.viewpager)
+    implementation(androidx.glance)
 
     implementation(androidx.bundles.lifecycle)
 

+ 14 - 0
app/src/main/AndroidManifest.xml

@@ -167,6 +167,20 @@
             android:name=".data.notification.NotificationReceiver"
             android:exported="false" />
 
+        <receiver
+            android:name=".glance.UpdatesGridGlanceReceiver"
+            android:enabled="@bool/glance_appwidget_available"
+            android:exported="false"
+            android:label="@string/label_recent_updates">
+            <intent-filter>
+                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.appwidget.provider"
+                android:resource="@xml/updates_grid_glance_widget_info" />
+        </receiver>
+
         <service
             android:name=".data.library.LibraryUpdateService"
             android:exported="false" />

+ 18 - 0
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -14,6 +14,7 @@ import android.webkit.WebView
 import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.app.NotificationManagerCompat
 import androidx.core.content.getSystemService
+import androidx.glance.appwidget.GlanceAppWidgetManager
 import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.ProcessLifecycleOwner
@@ -24,6 +25,7 @@ import coil.decode.GifDecoder
 import coil.decode.ImageDecoderDecoder
 import coil.disk.DiskCache
 import coil.util.DebugLogger
+import eu.kanade.data.DatabaseHandler
 import eu.kanade.domain.DomainModule
 import eu.kanade.tachiyomi.data.coil.DomainMangaKeyer
 import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
@@ -33,6 +35,7 @@ import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
 import eu.kanade.tachiyomi.data.notification.Notifications
 import eu.kanade.tachiyomi.data.preference.PreferenceValues
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.glance.UpdatesGridGlanceWidget
 import eu.kanade.tachiyomi.network.NetworkHelper
 import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
 import eu.kanade.tachiyomi.util.preference.asHotFlow
@@ -42,6 +45,8 @@ import eu.kanade.tachiyomi.util.system.animatorDurationScale
 import eu.kanade.tachiyomi.util.system.isDevFlavor
 import eu.kanade.tachiyomi.util.system.logcat
 import eu.kanade.tachiyomi.util.system.notification
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import logcat.AndroidLogcatLogger
@@ -125,6 +130,19 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
                 )
             }.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
 
+        // Updates widget update
+        Injekt.get<DatabaseHandler>()
+            .subscribeToList { updatesViewQueries.updates(after = UpdatesGridGlanceWidget.DateLimit.timeInMillis) }
+            .drop(1)
+            .distinctUntilChanged()
+            .onEach {
+                val manager = GlanceAppWidgetManager(this)
+                if (manager.getGlanceIds(UpdatesGridGlanceWidget::class.java).isNotEmpty()) {
+                    UpdatesGridGlanceWidget().loadData(it)
+                }
+            }
+            .launchIn(ProcessLifecycleOwner.get().lifecycleScope)
+
         if (!LogcatLogger.isInstalled && preferences.verboseLogging()) {
             LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
         }

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/glance/GlanceUtils.kt

@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.glance
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.glance.GlanceModifier
+import androidx.glance.LocalContext
+import androidx.glance.appwidget.cornerRadius
+import eu.kanade.tachiyomi.R
+
+fun GlanceModifier.appWidgetBackgroundRadius(): GlanceModifier {
+    return this.cornerRadius(R.dimen.appwidget_background_radius)
+}
+
+fun GlanceModifier.appWidgetInnerRadius(): GlanceModifier {
+    return this.cornerRadius(R.dimen.appwidget_inner_radius)
+}
+
+@Composable
+fun stringResource(@StringRes id: Int): String {
+    return LocalContext.current.getString(id)
+}

+ 8 - 0
app/src/main/java/eu/kanade/tachiyomi/glance/UpdatesGridGlanceReceiver.kt

@@ -0,0 +1,8 @@
+package eu.kanade.tachiyomi.glance
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() {
+    override val glanceAppWidget: GlanceAppWidget = UpdatesGridGlanceWidget().apply { loadData() }
+}

+ 287 - 0
app/src/main/java/eu/kanade/tachiyomi/glance/UpdatesGridGlanceWidget.kt

@@ -0,0 +1,287 @@
+package eu.kanade.tachiyomi.glance
+
+import android.app.Application
+import android.content.Intent
+import android.graphics.Bitmap
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.graphics.drawable.toBitmap
+import androidx.glance.GlanceModifier
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.CircularProgressIndicator
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.appWidgetBackground
+import androidx.glance.appwidget.updateAll
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.ContentScale
+import androidx.glance.layout.Row
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import coil.executeBlocking
+import coil.imageLoader
+import coil.request.CachePolicy
+import coil.request.ImageRequest
+import coil.size.Precision
+import coil.size.Scale
+import coil.transform.RoundedCornersTransformation
+import eu.kanade.data.DatabaseHandler
+import eu.kanade.domain.manga.model.MangaCover
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.ui.main.MainActivity
+import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.system.dpToPx
+import kotlinx.coroutines.MainScope
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import view.UpdatesView
+import java.util.Calendar
+import java.util.Date
+
+class UpdatesGridGlanceWidget : GlanceAppWidget() {
+    private val app: Application by injectLazy()
+    private val preferences: PreferencesHelper by injectLazy()
+
+    private val coroutineScope = MainScope()
+
+    var data: List<Pair<Long, Bitmap?>>? = null
+
+    override val sizeMode = SizeMode.Exact
+
+    @Composable
+    override fun Content() {
+        // App lock enabled, don't do anything
+        if (preferences.useAuthenticator().get()) {
+            WidgetNotAvailable()
+        } else {
+            UpdatesWidget()
+        }
+    }
+
+    @Composable
+    private fun WidgetNotAvailable() {
+        val intent = Intent(LocalContext.current, MainActivity::class.java).apply {
+            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        }
+        Box(
+            modifier = GlanceModifier
+                .clickable(actionStartActivity(intent))
+                .then(ContainerModifier)
+                .padding(8.dp),
+            contentAlignment = Alignment.Center,
+        ) {
+            Text(
+                text = stringResource(id = R.string.appwidget_unavailable_locked),
+                style = TextStyle(
+                    color = ColorProvider(R.color.appwidget_on_secondary_container),
+                    fontSize = 12.sp,
+                    textAlign = TextAlign.Center,
+                ),
+            )
+        }
+    }
+
+    @Composable
+    private fun UpdatesWidget() {
+        val (rowCount, columnCount) = LocalSize.current.calculateRowAndColumnCount()
+        Column(
+            modifier = ContainerModifier,
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            val inData = data
+            if (inData == null) {
+                CircularProgressIndicator()
+            } else if (inData.isEmpty()) {
+                Text(text = stringResource(id = R.string.information_no_recent))
+            } else {
+                (0 until rowCount).forEach { i ->
+                    val coverRow = (0 until columnCount).mapNotNull { j ->
+                        inData.getOrNull(j + (i * columnCount))
+                    }
+                    if (coverRow.isNotEmpty()) {
+                        Row(
+                            modifier = GlanceModifier
+                                .padding(vertical = 4.dp)
+                                .fillMaxWidth(),
+                            horizontalAlignment = Alignment.CenterHorizontally,
+                            verticalAlignment = Alignment.CenterVertically,
+                        ) {
+                            coverRow.forEach { (mangaId, cover) ->
+                                Box(
+                                    modifier = GlanceModifier
+                                        .padding(horizontal = 3.dp),
+                                    contentAlignment = Alignment.Center,
+                                ) {
+                                    val intent = Intent(LocalContext.current, MainActivity::class.java).apply {
+                                        action = MainActivity.SHORTCUT_MANGA
+                                        putExtra(MangaController.MANGA_EXTRA, mangaId)
+                                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                                        addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+
+                                        // https://issuetracker.google.com/issues/238793260
+                                        addCategory(mangaId.toString())
+                                    }
+                                    Cover(
+                                        modifier = GlanceModifier.clickable(actionStartActivity(intent)),
+                                        cover = cover,
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    @Composable
+    private fun Cover(
+        modifier: GlanceModifier = GlanceModifier,
+        cover: Bitmap?,
+    ) {
+        Box(
+            modifier = modifier
+                .size(width = CoverWidth, height = CoverHeight)
+                .appWidgetInnerRadius()
+                .background(ColorProvider(R.color.appwidget_surface_variant)),
+        ) {
+            if (cover != null) {
+                Image(
+                    provider = ImageProvider(cover),
+                    contentDescription = null,
+                    modifier = GlanceModifier
+                        .fillMaxSize()
+                        .appWidgetInnerRadius(),
+                    contentScale = ContentScale.Crop,
+                )
+            } else {
+                // Enjoy placeholder
+                Image(
+                    provider = ImageProvider(R.drawable.appwidget_cover_placeholder),
+                    contentDescription = null,
+                    modifier = GlanceModifier
+                        .fillMaxSize()
+                        .padding(4.dp),
+                    contentScale = ContentScale.Crop,
+                )
+            }
+        }
+    }
+
+    fun loadData(list: List<UpdatesView>? = null) {
+        coroutineScope.launchIO {
+            // Don't show anything when lock is active
+            if (preferences.useAuthenticator().get()) {
+                updateAll(app)
+                return@launchIO
+            }
+
+            val manager = GlanceAppWidgetManager(app)
+            val ids = manager.getGlanceIds(this@UpdatesGridGlanceWidget::class.java)
+            if (ids.isEmpty()) return@launchIO
+
+            val processList = list
+                ?: Injekt.get<DatabaseHandler>()
+                    .awaitList { updatesViewQueries.updates(after = DateLimit.timeInMillis) }
+            val (rowCount, columnCount) = ids
+                .flatMap { manager.getAppWidgetSizes(it) }
+                .maxBy { it.height.value * it.width.value }
+                .calculateRowAndColumnCount()
+
+            data = prepareList(processList, rowCount * columnCount)
+            ids.forEach { update(app, it) }
+        }
+    }
+
+    private fun prepareList(processList: List<UpdatesView>, take: Int): List<Pair<Long, Bitmap?>> {
+        // Resize to cover size
+        val widthPx = CoverWidth.value.toInt().dpToPx
+        val heightPx = CoverHeight.value.toInt().dpToPx
+        val roundPx = app.resources.getDimension(R.dimen.appwidget_inner_radius)
+        return processList
+            .distinctBy { it.mangaId }
+            .take(take)
+            .map { updatesView ->
+                val request = ImageRequest.Builder(app)
+                    .data(
+                        MangaCover(
+                            mangaId = updatesView.mangaId,
+                            sourceId = updatesView.source,
+                            isMangaFavorite = updatesView.favorite,
+                            url = updatesView.thumbnailUrl,
+                            lastModified = updatesView.coverLastModified,
+                        ),
+                    )
+                    .memoryCachePolicy(CachePolicy.DISABLED)
+                    .precision(Precision.EXACT)
+                    .size(widthPx, heightPx)
+                    .scale(Scale.FILL)
+                    .let {
+                        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+                            it.transformations(RoundedCornersTransformation(roundPx))
+                        } else it // Handled by system
+                    }
+                    .build()
+                Pair(updatesView.mangaId, app.imageLoader.executeBlocking(request).drawable?.toBitmap())
+            }
+    }
+
+    companion object {
+        val DateLimit: Calendar
+            get() = Calendar.getInstance().apply {
+                time = Date()
+                add(Calendar.MONTH, -3)
+            }
+    }
+}
+
+private val CoverWidth = 58.dp
+private val CoverHeight = 87.dp
+
+private val ContainerModifier = GlanceModifier
+    .fillMaxSize()
+    .background(ImageProvider(R.drawable.appwidget_background))
+    .appWidgetBackground()
+    .appWidgetBackgroundRadius()
+
+/**
+ * Calculates row-column count.
+ *
+ * Row
+ * Numerator: Container height - container vertical padding
+ * Denominator: Cover height + cover vertical padding
+ *
+ * Column
+ * Numerator: Container width - container horizontal padding
+ * Denominator: Cover width + cover horizontal padding
+ *
+ * @return pair of row and column count
+ */
+private fun DpSize.calculateRowAndColumnCount(): Pair<Int, Int> {
+    // Hack: Size provided by Glance manager is not reliable so take at least 1 row and 1 column
+    val rowCount = (height.value / 95).toInt().coerceAtLeast(1)
+    val columnCount = (width.value / 64).toInt().coerceAtLeast(1)
+    return Pair(rowCount, columnCount)
+}

BIN
app/src/main/res/drawable-nodpi/updates_grid_widget_preview.webp


+ 6 - 0
app/src/main/res/drawable/appwidget_background.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="@color/appwidget_secondary_container" />
+    <corners android:radius="@dimen/appwidget_background_radius" />
+</shape>

+ 9 - 0
app/src/main/res/drawable/appwidget_cover_placeholder.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@color/appwidget_background"
+        android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z" />
+</vector>

+ 14 - 0
app/src/main/res/layout/appwidget_loading.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@drawable/appwidget_background">
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:text="@string/loading"
+        android:textColor="?android:attr/textColorPrimary" />
+
+</FrameLayout>

+ 9 - 0
app/src/main/res/values-night-v31/colors_appwidget.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="appwidget_background">@color/m3_sys_color_dynamic_dark_surface</color>
+    <color name="appwidget_on_background">@color/m3_sys_color_dynamic_dark_on_surface</color>
+    <color name="appwidget_surface_variant">@color/m3_sys_color_dynamic_dark_surface_variant</color>
+    <color name="appwidget_on_surface_variant">@color/m3_sys_color_dynamic_dark_on_surface_variant</color>
+    <color name="appwidget_secondary_container">@color/m3_sys_color_dynamic_dark_secondary_container</color>
+    <color name="appwidget_on_secondary_container">@color/m3_sys_color_dynamic_dark_on_secondary_container</color>
+</resources>

+ 9 - 0
app/src/main/res/values-v31/colors_appwidget.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="appwidget_background">@color/m3_sys_color_dynamic_light_surface</color>
+    <color name="appwidget_on_background">@color/m3_sys_color_dynamic_light_on_surface</color>
+    <color name="appwidget_surface_variant">@color/m3_sys_color_dynamic_light_surface_variant</color>
+    <color name="appwidget_on_surface_variant">@color/m3_sys_color_dynamic_light_on_surface_variant</color>
+    <color name="appwidget_secondary_container">@color/m3_sys_color_dynamic_light_secondary_container</color>
+    <color name="appwidget_on_secondary_container">@color/m3_sys_color_dynamic_light_on_secondary_container</color>
+</resources>

+ 3 - 0
app/src/main/res/values-v31/dimens.xml

@@ -0,0 +1,3 @@
+<resources>
+    <dimen name="appwidget_background_radius">@android:dimen/system_app_widget_background_radius</dimen>
+</resources>

+ 9 - 0
app/src/main/res/values/colors_appwidget.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="appwidget_background">@color/tachiyomi_surface</color>
+    <color name="appwidget_on_background">@color/tachiyomi_onSurface</color>
+    <color name="appwidget_surface_variant">@color/tachiyomi_surfaceVariant</color>
+    <color name="appwidget_on_surface_variant">@color/tachiyomi_onSurfaceVariant</color>
+    <color name="appwidget_secondary_container">@color/tachiyomi_secondaryContainer</color>
+    <color name="appwidget_on_secondary_container">@color/tachiyomi_onSecondaryContainer</color>
+</resources>

+ 3 - 0
app/src/main/res/values/dimens.xml

@@ -15,4 +15,7 @@
 
     <dimen name="tablet_horizontal_cover_margin">128dp</dimen>
     <dimen name="tablet_sidebar_max_width">450dp</dimen>
+
+    <dimen name="appwidget_background_radius">16dp</dimen>
+    <dimen name="appwidget_inner_radius">12dp</dimen>
 </resources>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -853,4 +853,8 @@
     <!-- S Pen actions -->
     <string name="spen_previous_page">Previous page</string>
     <string name="spen_next_page">Next page</string>
+
+    <!-- App widget -->
+    <string name="appwidget_updates_description">See your recently updated manga</string>
+    <string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>
 </resources>

+ 15 - 0
app/src/main/res/xml/updates_grid_glance_widget_info.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
+    android:description="@string/appwidget_updates_description"
+    android:previewImage="@drawable/updates_grid_widget_preview"
+    android:initialLayout="@layout/appwidget_loading"
+    android:minWidth="240dp"
+    android:minHeight="80dp"
+    android:minResizeWidth="80dp"
+    android:minResizeHeight="110dp"
+    android:maxResizeWidth="600dp"
+    android:maxResizeHeight="600dp"
+    android:targetCellWidth="4"
+    android:targetCellHeight="2"
+    android:resizeMode="horizontal|vertical"
+    android:widgetCategory="home_screen" />

+ 1 - 0
gradle/androidx.versions.toml

@@ -12,6 +12,7 @@ corektx = "androidx.core:core-ktx:1.8.0"
 splashscreen = "androidx.core:core-splashscreen:1.0.0-alpha02"
 recyclerview = "androidx.recyclerview:recyclerview:1.3.0-beta01"
 viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01"
+glance = "androidx.glance:glance-appwidget:1.0.0-alpha03"
 
 lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle_version" }
 lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle_version" }