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" }