diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 227b3b2045..1519cb2fde 100644
--- a/app/build.gradle.kts
+++ b/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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3fc615c534..c639dc5833 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -167,6 +167,20 @@
android:name=".data.notification.NotificationReceiver"
android:exported="false" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt
index 4715471407..7495af59de 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/App.kt
+++ b/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()
+ .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))
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/glance/GlanceUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/glance/GlanceUtils.kt
new file mode 100644
index 0000000000..5153183d65
--- /dev/null
+++ b/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)
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/glance/UpdatesGridGlanceReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/glance/UpdatesGridGlanceReceiver.kt
new file mode 100644
index 0000000000..0fb1535002
--- /dev/null
+++ b/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() }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/glance/UpdatesGridGlanceWidget.kt b/app/src/main/java/eu/kanade/tachiyomi/glance/UpdatesGridGlanceWidget.kt
new file mode 100644
index 0000000000..816cfefa76
--- /dev/null
+++ b/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>? = 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? = 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()
+ .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, take: Int): List> {
+ // 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 {
+ // 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)
+}
diff --git a/app/src/main/res/drawable-nodpi/updates_grid_widget_preview.webp b/app/src/main/res/drawable-nodpi/updates_grid_widget_preview.webp
new file mode 100644
index 0000000000..44b9b05d56
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/updates_grid_widget_preview.webp differ
diff --git a/app/src/main/res/drawable/appwidget_background.xml b/app/src/main/res/drawable/appwidget_background.xml
new file mode 100644
index 0000000000..3b99826f58
--- /dev/null
+++ b/app/src/main/res/drawable/appwidget_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/appwidget_cover_placeholder.xml b/app/src/main/res/drawable/appwidget_cover_placeholder.xml
new file mode 100644
index 0000000000..c6489cc235
--- /dev/null
+++ b/app/src/main/res/drawable/appwidget_cover_placeholder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/appwidget_loading.xml b/app/src/main/res/layout/appwidget_loading.xml
new file mode 100644
index 0000000000..88a57693fe
--- /dev/null
+++ b/app/src/main/res/layout/appwidget_loading.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night-v31/colors_appwidget.xml b/app/src/main/res/values-night-v31/colors_appwidget.xml
new file mode 100644
index 0000000000..93df05d201
--- /dev/null
+++ b/app/src/main/res/values-night-v31/colors_appwidget.xml
@@ -0,0 +1,9 @@
+
+
+ @color/m3_sys_color_dynamic_dark_surface
+ @color/m3_sys_color_dynamic_dark_on_surface
+ @color/m3_sys_color_dynamic_dark_surface_variant
+ @color/m3_sys_color_dynamic_dark_on_surface_variant
+ @color/m3_sys_color_dynamic_dark_secondary_container
+ @color/m3_sys_color_dynamic_dark_on_secondary_container
+
diff --git a/app/src/main/res/values-v31/colors_appwidget.xml b/app/src/main/res/values-v31/colors_appwidget.xml
new file mode 100644
index 0000000000..2c4d91f5b3
--- /dev/null
+++ b/app/src/main/res/values-v31/colors_appwidget.xml
@@ -0,0 +1,9 @@
+
+
+ @color/m3_sys_color_dynamic_light_surface
+ @color/m3_sys_color_dynamic_light_on_surface
+ @color/m3_sys_color_dynamic_light_surface_variant
+ @color/m3_sys_color_dynamic_light_on_surface_variant
+ @color/m3_sys_color_dynamic_light_secondary_container
+ @color/m3_sys_color_dynamic_light_on_secondary_container
+
diff --git a/app/src/main/res/values-v31/dimens.xml b/app/src/main/res/values-v31/dimens.xml
new file mode 100644
index 0000000000..f4a3262806
--- /dev/null
+++ b/app/src/main/res/values-v31/dimens.xml
@@ -0,0 +1,3 @@
+
+ @android:dimen/system_app_widget_background_radius
+
diff --git a/app/src/main/res/values/colors_appwidget.xml b/app/src/main/res/values/colors_appwidget.xml
new file mode 100644
index 0000000000..7d07ea1f8a
--- /dev/null
+++ b/app/src/main/res/values/colors_appwidget.xml
@@ -0,0 +1,9 @@
+
+
+ @color/tachiyomi_surface
+ @color/tachiyomi_onSurface
+ @color/tachiyomi_surfaceVariant
+ @color/tachiyomi_onSurfaceVariant
+ @color/tachiyomi_secondaryContainer
+ @color/tachiyomi_onSecondaryContainer
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 4e4c2b238c..f7cbcc6701 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -15,4 +15,7 @@
128dp
450dp
+
+ 16dp
+ 12dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 19947c6b62..e65e818f02 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -853,4 +853,8 @@
Previous page
Next page
+
+
+ See your recently updated manga
+ Widget not available when app lock is enabled
diff --git a/app/src/main/res/xml/updates_grid_glance_widget_info.xml b/app/src/main/res/xml/updates_grid_glance_widget_info.xml
new file mode 100644
index 0000000000..640e4bf11c
--- /dev/null
+++ b/app/src/main/res/xml/updates_grid_glance_widget_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml
index 4965242ba4..2af2c2da0b 100644
--- a/gradle/androidx.versions.toml
+++ b/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" }