diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 818eb6e5..7c43811e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -272,6 +272,7 @@ dependencies { // WorkManager (background downloads, foreground-service notification) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.glance.appwidget) // Play Billing (one-time unlock) implementation(libs.billing.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2116e661..e7d30d7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -85,6 +85,19 @@ + + + + + + + + navController.navigate(Routes.bookDetail(route.bookId)) { - launchSingleTop = true + is WidgetRoute.Book -> { + val top = navController.currentBackStackEntry + if (opensNewBookPage(top?.destination?.route, top?.arguments?.getString("bookId"), route.bookId)) { + navController.navigate(Routes.bookDetail(route.bookId)) + } } WidgetRoute.Unlock -> navController.navigate(Routes.UNLOCK) { launchSingleTop = true diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt index 13b16605..86715197 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt @@ -132,7 +132,7 @@ class HomeViewModel @Inject constructor( if (libraryId != null && canShowHomeBooks(settings, hasAuthToken)) { // The recent-books flow re-emits on any AudioBooks change, so // a folder import refreshes the book count along with it. - audioBookDao.observeRecentlyPlayedByLibrary(libraryId, 9) + audioBookDao.observeRecentlyPlayedByLibrary(libraryId, RECENT_LIVES_LIMIT) .collect { results -> processRecentlyPlayed(results, libraryId) } } else { processRecentlyPlayed(emptyList(), libraryId = null) @@ -154,7 +154,7 @@ class HomeViewModel @Inject constructor( ) } val results = if (visibleLibraryId != null) { - audioBookDao.getRecentlyPlayedByLibrary(visibleLibraryId, 9) + audioBookDao.getRecentlyPlayedByLibrary(visibleLibraryId, RECENT_LIVES_LIMIT) } else { emptyList() } @@ -243,7 +243,7 @@ class HomeViewModel @Inject constructor( } // Aggregate total listening time - val totalSeconds = results.sumOf { it.audioBook.currentTimeSeconds } + val totalSeconds = totalTimeGivenSeconds(results.map { it.audioBook.currentTimeSeconds }) val totalText = formatListeningTime(totalSeconds) // Counted only when Home is empty. A failed count reads as no books, @@ -303,17 +303,6 @@ class HomeViewModel @Inject constructor( // ─── Time Formatting ───────────────────────────────────────────────────── - /** Formats seconds as compact listening time: "< 1m", "45m", "3h 22m" */ - private fun formatListeningTime(totalSeconds: Double): String { - val totalMinutes = totalSeconds / 60.0 - if (totalMinutes < 1) return "< 1m" - val totalHours = totalSeconds / 3600.0 - if (totalHours < 1) return "${totalMinutes.toInt()}m" - val hours = totalHours.toInt() - val mins = ((totalSeconds % 3600) / 60).toInt() - return "${hours}h ${mins}m" - } - /** Formats an ISO-8601 timestamp as relative time: "3h ago", "Yesterday", etc. */ private fun formatRelativeTime(timestamp: String?): String { if (timestamp.isNullOrEmpty()) return "" diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt new file mode 100644 index 00000000..cc1b2c5e --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt @@ -0,0 +1,26 @@ +package com.ninelivesaudio.app.ui.home + +/* + * The altar card's numbers, shared by Home and the Nine Lives home screen + * widget so the two always say the same thing. + */ + +/** How many recent books Home shows as Lives, and sums into "Total time given". */ +internal const val RECENT_LIVES_LIMIT = 9 + +/** Formats seconds as compact listening time: "< 1m", "45m", "3h 22m". */ +internal fun formatListeningTime(totalSeconds: Double): String { + val totalMinutes = totalSeconds / 60.0 + if (totalMinutes < 1) return "< 1m" + val totalHours = totalSeconds / 3600.0 + if (totalHours < 1) return "${totalMinutes.toInt()}m" + val hours = totalHours.toInt() + val mins = ((totalSeconds % 3600) / 60).toInt() + return "${hours}h ${mins}m" +} + +/** + * "Total time given": the saved positions of the recent Lives, added up. + * This is Home's long standing math, not lifetime listening. + */ +internal fun totalTimeGivenSeconds(currentTimesSeconds: List): Double = currentTimesSeconds.sum() diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt index 720b52df..762285d3 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt @@ -43,6 +43,7 @@ class WidgetActionActivity : ComponentActivity() { val (bookId, tap) = when (launch) { is WidgetLaunch.PlayBook -> launch.bookId to widgetPlayback.binderTap(launch) is WidgetLaunch.OpenBook -> launch.bookId to widgetPlayback.binderTap(launch) + is WidgetLaunch.TogglePlay -> launch.bookId to widgetPlayback.toggleTap(launch) null, WidgetLaunch.Nothing -> { finish() return diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt index 39da26c7..f5bd2ce7 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt @@ -18,7 +18,7 @@ internal const val BINDER_COVER_PX = 192 internal const val HOME_CURRENT_COVER_PX = 192 /** One of the large widget's next Lives tiles (W2). */ -internal const val HOME_LIFE_COVER_PX = 96 +internal const val HOME_LIFE_COVER_PX = 128 /** Bitmap bytes one RemoteViews payload may carry, well under the 1 MB transaction. */ internal const val WIDGET_BITMAP_BUDGET_BYTES = 600 * 1024 diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt index a55cbbb4..1335f370 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt @@ -14,6 +14,9 @@ internal sealed interface WidgetLaunch { data class PlayBook(val bookId: String) : WidgetLaunch data object OpenUnlock : WidgetLaunch + /** The large widget's play or pause button, for the book it shows. */ + data class TogglePlay(val bookId: String) : WidgetLaunch + /** A tap on an empty sleeve. The trampoline finishes without launching anything. */ data object Nothing : WidgetLaunch } @@ -27,12 +30,14 @@ internal fun WidgetLaunch.actionName(): String = when (this) { is WidgetLaunch.PlayBook -> "play_book" WidgetLaunch.OpenUnlock -> "open_unlock" WidgetLaunch.Nothing -> "empty" + is WidgetLaunch.TogglePlay -> "toggle_play" } internal val WidgetLaunch.bookIdOrNull: String? get() = when (this) { is WidgetLaunch.OpenBook -> bookId is WidgetLaunch.PlayBook -> bookId + is WidgetLaunch.TogglePlay -> bookId else -> null } @@ -45,10 +50,19 @@ internal fun parseWidgetLaunch(action: String?, bookId: String?): WidgetLaunch? "empty" -> WidgetLaunch.Nothing "open_book" -> id?.let { WidgetLaunch.OpenBook(it) } "play_book" -> id?.let { WidgetLaunch.PlayBook(it) } + "toggle_play" -> id?.let { WidgetLaunch.TogglePlay(it) } else -> null } } +/** + * Whether a widget's Open tap needs a new book page. Only the same book + * already on top is left alone. Navigation's single top matches the route + * pattern, not the book, so it would keep another book's page instead. + */ +internal fun opensNewBookPage(topRoute: String?, topBookId: String?, bookId: String): Boolean = + !(topRoute == com.ninelivesaudio.app.ui.navigation.Routes.BOOK_DETAIL && topBookId == bookId) + /** The screen `MainActivity` opens for a widget launch. */ internal sealed interface WidgetRoute { data class Book(val bookId: String) : WidgetRoute @@ -65,6 +79,7 @@ internal fun widgetRoute(launch: WidgetLaunch): WidgetRoute? = when (launch) { is WidgetLaunch.OpenBook -> WidgetRoute.Book(launch.bookId) WidgetLaunch.OpenUnlock -> WidgetRoute.Unlock is WidgetLaunch.PlayBook -> null + is WidgetLaunch.TogglePlay -> null } internal fun Intent.putWidgetLaunch(launch: WidgetLaunch): Intent = apply { @@ -81,6 +96,16 @@ internal fun widgetMainIntent(context: Context, launch: WidgetLaunch): Intent = .putWidgetLaunch(launch) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) +/** + * A tap the trampoline decides (Play, Open its page, play or pause), as a + * finished intent. The data URI keeps each launch's PendingIntent its own, + * since Android ignores extras when it matches PendingIntents. + */ +internal fun widgetActionIntent(context: Context, launch: WidgetLaunch): Intent = + widgetActionIntent(context) + .putWidgetLaunch(launch) + .setData(android.net.Uri.parse("ninelives-widget://${launch.actionName()}/${launch.bookIdOrNull.orEmpty()}")) + /** The Binder's fill in template. Each cover adds its own extras. */ internal fun widgetActionIntent(context: Context): Intent = Intent(context, WidgetActionActivity::class.java) diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt index d6217cdf..fa4c9542 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt @@ -41,10 +41,10 @@ internal class WidgetTap(val plan: Deferred, val act: Deferred WidgetPlayPlan.LOAD_BOOK } -/** A play or pause button on a widget, acting like the mini player's. */ +/** + * A play or pause button on a widget, acting like the mini player's. A locked + * tap goes to Unlock, and a shown book that cannot play right now opens its + * page, the same as a Binder cover (plan Design delta 5). + */ internal fun togglePlan( isUnlocked: Boolean, currentBookId: String?, @@ -76,13 +80,33 @@ internal fun togglePlan( bookLoading: Boolean, shownBookId: String?, shownPlayable: Boolean, + shownInActiveLibrary: Boolean = true, +): WidgetPlayPlan = togglePlanLazy( + isUnlocked, currentBookId, state, bookLoading, shownBookId, shownInActiveLibrary, +) { shownPlayable } + +/** + * Same decision as [togglePlan], but playability is only asked for when the + * tap would load a book. Pausing never waits on a server probe. + */ +internal inline fun togglePlanLazy( + isUnlocked: Boolean, + currentBookId: String?, + state: PlaybackState, + bookLoading: Boolean, + shownBookId: String?, + shownInActiveLibrary: Boolean = true, + shownPlayable: () -> Boolean, ): WidgetPlayPlan = when { - !isUnlocked -> WidgetPlayPlan.IGNORE + !isUnlocked -> WidgetPlayPlan.OPEN_UNLOCK + // A stale button (widget not yet redrawn after a library or mode switch) + // never touches the new library's playback. + !shownInActiveLibrary -> WidgetPlayPlan.IGNORE isLoading(state, bookLoading) -> WidgetPlayPlan.IGNORE isAudible(state) -> WidgetPlayPlan.PAUSE currentBookId != null -> WidgetPlayPlan.RESUME_CURRENT shownBookId == null -> WidgetPlayPlan.IGNORE - !shownPlayable -> WidgetPlayPlan.IGNORE + !shownPlayable() -> WidgetPlayPlan.OPEN_BOOK_PAGE else -> WidgetPlayPlan.LOAD_BOOK } @@ -228,8 +252,10 @@ class WidgetPlayback @Inject constructor( * Book Detail's Play gates: not archived, source reachable, remote media * not blocked. A streamed book that reads blocked gets one server check * first (see [settledConnectionStatus]), since a cold process has not run one. + * A redraw passes [settle] false: it reads the status as it is, and the + * check landing later redraws the widget anyway. */ - internal suspend fun isPlayable(book: AudioBook): Boolean { + internal suspend fun isPlayable(book: AudioBook, settle: Boolean = true): Boolean { val library = book.libraryId?.let { libraryRepository.getById(it) } val access = reconcileLocalBookAccess( book, @@ -242,6 +268,7 @@ class WidgetPlayback @Inject constructor( connectionStatus = status, ) if (decision(connectivityMonitor.connectionStatus.value) !is RemoteMediaAccessDecision.Blocked) return true + if (!settle) return false val status = settledConnectionStatus(current = { connectivityMonitor.connectionStatus.value }) { connectivityMonitor.refreshIsOnlineFromSystem() withTimeoutOrNull(WIDGET_SERVER_CHECK_WAIT) { connectivityMonitor.checkServerReachable() } @@ -289,8 +316,17 @@ class WidgetPlayback @Inject constructor( } } - internal suspend fun toggle(shownBookId: String?): WidgetPlayPlan = - scope.async { toggleOwned(shownBookId) }.await() + /** + * The large widget's play or pause button. Decided on this class's scope + * like a Binder tap, so the handoff screen can hold for the foreground + * start (WidgetActionActivity) without owning the load. + */ + internal fun toggleTap(launch: WidgetLaunch.TogglePlay): WidgetTap { + val act = CompletableDeferred() + val plan = scope.async { toggleOwned(launch.bookId, act) } + plan.invokeOnCompletion { if (it != null) act.complete(Unit) } + return WidgetTap(plan, act) + } /** The tapped book, only if it belongs to the library the app shows now. */ private suspend fun activeBook(bookId: String): AudioBook? = @@ -325,26 +361,25 @@ class WidgetPlayback @Inject constructor( binderOpenPlan(isUnlocked = true, inActiveLibrary = activeBook(bookId) != null) } - private suspend fun toggleOwned(shownBookId: String?): WidgetPlayPlan { - playbackManager.awaitStartupRestore() - val unlocked = effectiveSettings.isUnlockedNow - val shown = if (unlocked && shownBookId != null) { - withContext(Dispatchers.IO) { audioBookRepository.getById(shownBookId) } - ?.takeIf { it.isInActiveLibrary(settingsManager.currentSettings) } - } else { - null - } - val plan = togglePlan( - isUnlocked = unlocked, + private suspend fun toggleOwned(shownBookId: String, act: CompletableDeferred): WidgetPlayPlan = decideBinderTap( + isUnlocked = { effectiveSettings.isUnlockedNow }, + awaitReady = { playbackManager.awaitStartupRestore() }, + ) { + // A tap from a widget not yet redrawn after a mode or account switch + // carries a book from the old library. It never plays or opens. + val shown = activeBook(shownBookId) + val plan = togglePlanLazy( + isUnlocked = true, currentBookId = playbackManager.currentBook.value?.id, state = playbackManager.playbackState.value, bookLoading = playbackManager.loadingBook.value != null, shownBookId = shown?.id, - shownPlayable = shown != null && isPlayable(shown), + shownInActiveLibrary = shown != null, + shownPlayable = { shown != null && isPlayable(shown) }, ) - launchAct(plan, shown) - return plan - } + launchAct(plan, shown, act) + plan + }.also { if (it == WidgetPlayPlan.OPEN_UNLOCK) act.complete(Unit) } private fun launchAct(plan: WidgetPlayPlan, book: AudioBook?, done: CompletableDeferred? = null) { Log.d(TAG, "plan $plan for ${book?.id}") diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt index 9383acc9..d4428027 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt @@ -27,14 +27,19 @@ internal data class WidgetTrigger( val tick: Long, /** Covers picked or undone in the app (CoverPicker). */ val coverCount: Long = 0, + /** A book is loading in the player. Its end redraws, so a failed load never leaves the dimmed button. */ + val isLoading: Boolean = false, ) +/** The player's part of a [WidgetTrigger]. */ +internal data class PlaybackTrigger(val bookId: String?, val isPlaying: Boolean, val chapterIndex: Int, val isLoading: Boolean) + enum class WidgetTarget { BINDER, HOME } /** * Which widgets need a redraw. Unlock, identity, sync, scan, a picked or - * undone cover and the current book change what both show. Play state, chapter, reachability and the - * playing tick only change the large widget's current book card. + * undone cover and the current book change what both show. Play state, a load starting or + * ending, chapter, reachability and the playing tick only change the large widget's current book card. */ internal fun widgetRefreshTargets(prev: WidgetTrigger?, next: WidgetTrigger): Set { if (prev == null) return setOf(WidgetTarget.BINDER, WidgetTarget.HOME) @@ -46,6 +51,7 @@ internal fun widgetRefreshTargets(prev: WidgetTrigger?, next: WidgetTrigger): Se prev.currentBookId != next.currentBookId ) return setOf(WidgetTarget.BINDER, WidgetTarget.HOME) if (prev.isPlaying != next.isPlaying || + prev.isLoading != next.isLoading || prev.chapterIndex != next.chapterIndex || prev.serverReachable != next.serverReachable || prev.tick != next.tick diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt index 63886af9..17f7ff64 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt @@ -15,6 +15,10 @@ import com.ninelivesaudio.app.service.cover.CoverPicker import com.ninelivesaudio.app.service.local.LocalScanCoordinator import com.ninelivesaudio.app.service.local.LocalScanState import com.ninelivesaudio.app.widget.binder.BinderWidgetUpdater +import com.ninelivesaudio.app.widget.home.HomeWidgetStore +import com.ninelivesaudio.app.widget.home.NineLivesHomeWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.updateAll import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -54,6 +58,7 @@ class WidgetRefresher @Inject constructor( private val connectivityMonitor: ConnectivityMonitor, private val binderUpdater: BinderWidgetUpdater, private val coverPicker: CoverPicker, + private val homeStore: HomeWidgetStore, ) { private var started = false private val pushLock = Mutex() @@ -100,7 +105,8 @@ class WidgetRefresher @Inject constructor( playbackManager.currentBook.map { it?.id }, playbackManager.playbackState.map { isPlayingForWidget(it) }, playbackManager.currentChapterIndex, - ) { id, playing, chapter -> Triple(id, playing, chapter) } + playbackManager.loadingBook.map { it != null }, + ) { id, playing, chapter, loading -> PlaybackTrigger(id, playing, chapter, loading) } val counters = combine(syncCount, scanCount, tick, coverPicker.changeCount) { a, b, c, d -> listOf(a, b, c, d) } val reachable = connectivityMonitor.connectionStatus.map { it == ConnectivityMonitor.ConnectionStatus.CONNECTED } @@ -110,9 +116,10 @@ class WidgetRefresher @Inject constructor( WidgetTrigger( isUnlocked = unlocked, identity = id, - currentBookId = play.first, - isPlaying = play.second, - chapterIndex = play.third, + currentBookId = play.bookId, + isPlaying = play.isPlaying, + chapterIndex = play.chapterIndex, + isLoading = play.isLoading, serverReachable = reach, syncCount = count[0], scanCount = count[1], @@ -138,7 +145,7 @@ class WidgetRefresher @Inject constructor( } } - /** Redraws [targets] now. HOME is a no op until the large widget exists (W2). */ + /** Redraws [targets] now. A widget kind with none on the home screen reads nothing. */ suspend fun pushNow(targets: Set) = withContext(Dispatchers.IO) { pushLock.withLock { Log.d(TAG, "push $targets") @@ -146,6 +153,15 @@ class WidgetRefresher @Inject constructor( runCatching { binderUpdater.updateAll() } .onFailure { Log.w(TAG, "binder update failed: ${it.message}") } } + if (WidgetTarget.HOME in targets) { + runCatching { + val placed = GlanceAppWidgetManager(context).getGlanceIds(NineLivesHomeWidget::class.java) + if (placed.isNotEmpty()) { + homeStore.refresh() + NineLivesHomeWidget().updateAll(context) + } + }.onFailure { Log.w(TAG, "home update failed: ${it.message}") } + } } } diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt new file mode 100644 index 00000000..bc2726aa --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -0,0 +1,67 @@ +package com.ninelivesaudio.app.widget.home + +import com.ninelivesaudio.app.widget.HOME_CURRENT_COVER_PX +import com.ninelivesaudio.app.widget.HOME_LIFE_COVER_PX +import com.ninelivesaudio.app.widget.rgb565Bytes + +/** + * What the Nine Lives widget keeps at a size. The design's priority order is + * rank card, then current book, then the next Lives, and smaller sizes drop + * from the bottom of that list: Lives first, then the rank card. The current + * book with its play button is in every layout. + */ +internal enum class HomeLayout { CURRENT_ONLY, RANK_AND_CURRENT, FULL } + +/** Width and height in dp, as Glance hands them to the layout. */ +internal fun homeLayoutFor(widthDp: Float, heightDp: Float): HomeLayout = when { + widthDp < 250f || heightDp < 180f -> HomeLayout.CURRENT_ONLY + heightDp < 260f -> HomeLayout.RANK_AND_CURRENT + else -> HomeLayout.FULL +} + +/** How many next Lives fit (decision J3): three under 300dp wide, four from 300dp. */ +internal fun livesSlotsFor(widthDp: Float): Int = if (widthDp < 300f) 3 else 4 + +/** The widest a Lives tile grows, so the full face still fits its height. */ +internal const val MAX_LIFE_TILE_DP = 72f + +/** Gap between two Lives tiles. */ +internal const val LIFE_GAP_DP = 10f + +/** + * One square Lives tile, in dp: [slots] tiles and their gaps share the width + * inside the widget's 12dp padding, capped at [MAX_LIFE_TILE_DP]. + */ +internal fun lifeTileDp(widthDp: Float, slots: Int): Float = + minOf(MAX_LIFE_TILE_DP, (widthDp - 24f - LIFE_GAP_DP * (slots - 1)) / slots) + +/** Most Lives any layout shows. */ +internal const val MAX_LIVES = 4 + +/** + * The Glance `SizeMode.Responsive` buckets, width to height in dp. Glance + * lays out each bucket once and the launcher picks the biggest that fits, so + * the layout only ever sees these sizes. One starts at 300dp wide so a widget + * that wide gets its four Lives (J3). The smallest matches the resize floor in + * widget_home_info.xml, since a launcher rounds cells and a 2x2 or a one row + * widget can come out under 180 by 110 (Pixel: about 172 by 99). + */ +internal val HOME_WIDGET_SIZES: List> = + listOf(160f to 80f, 160f to 180f, 250f to 180f, 250f to 260f, 300f to 260f) + +/** A current only widget tall enough to stack the cover and play above the title (a 2x2). */ +internal fun isTallCurrentOnly(widthDp: Float, heightDp: Float): Boolean = + homeLayoutFor(widthDp, heightDp) == HomeLayout.CURRENT_ONLY && heightDp >= 180f + +/** Cover bytes one bucket draws: the current book, plus the Lives only where they show. */ +internal fun homeBucketBitmapBytes(widthDp: Float, heightDp: Float): Int { + val lives = if (homeLayoutFor(widthDp, heightDp) == HomeLayout.FULL) livesSlotsFor(widthDp) else 0 + return rgb565Bytes(HOME_CURRENT_COVER_PX) + lives * rgb565Bytes(HOME_LIFE_COVER_PX) +} + +/** + * The most cover bytes one update can carry. API 30 sends one RemoteViews per + * size and does not share a bitmap between them, so every bucket counts. + */ +internal fun homeWorstCaseBitmapBytes(): Int = + HOME_WIDGET_SIZES.sumOf { (w, h) -> homeBucketBitmapBytes(w, h) } diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt new file mode 100644 index 00000000..0267d13f --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt @@ -0,0 +1,130 @@ +package com.ninelivesaudio.app.widget.home + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.Chapter +import com.ninelivesaudio.app.service.PlaybackState +import com.ninelivesaudio.app.ui.home.AltarCopy +import com.ninelivesaudio.app.ui.home.RECENT_LIVES_LIMIT +import com.ninelivesaudio.app.ui.home.formatListeningTime +import com.ninelivesaudio.app.ui.home.totalTimeGivenSeconds +import com.ninelivesaudio.app.widget.WidgetAccess +import com.ninelivesaudio.app.widget.WidgetBookSource +import com.ninelivesaudio.app.widget.WidgetContent +import com.ninelivesaudio.app.widget.widgetContent +import kotlin.time.DurationUnit + +/** The current book's button. WAIT is a dimmed play while a book loads, like the app. */ +internal enum class CurrentButton { PLAY, PAUSE, WAIT, OPEN } + +/** Home's altar card, the same words Home shows. */ +internal data class RankCard(val epithet: String, val totalTimeText: String) + +internal data class CurrentCard( + val bookId: String, + val title: String, + val chapterTitle: String?, + val button: CurrentButton, +) + +internal data class LifeTile(val bookId: String, val title: String) + +/** + * Everything the Nine Lives widget draws, apart from cover art. Anything but + * READY carries no rank, book or Lives, so a locked or empty widget cannot + * show a stale one. + */ +internal data class HomeWidgetModel( + val content: WidgetContent, + val isLocalMode: Boolean, + val rank: RankCard?, + val current: CurrentCard?, + val lives: List, +) + +/** The library reads one widget update makes. */ +internal data class HomeWidgetInputs( + val folderAccessible: Boolean, + val bookCount: Int, + val recent: List, +) + +/** The chapter playing at [positionSeconds]. Past the end is the last chapter. */ +internal fun chapterTitleAt(chapters: List, positionSeconds: Double): String? { + if (chapters.isEmpty()) return null + val sorted = chapters.sortedBy { it.start } + return (sorted.lastOrNull { it.start <= positionSeconds } ?: sorted.first()).title +} + +/** + * Builds the widget. The shown book is the one in the player, else the most + * recently played. The rank card is Home's: the same nine recent books, the + * same sum, the same words (plan Design delta 7). + */ +internal fun buildHomeWidgetModel( + access: WidgetAccess, + folderAccessible: Boolean, + bookCount: Int, + recent: List, + current: AudioBook?, + liveChapterTitle: String?, + state: PlaybackState, + bookLoading: Boolean, + shownPlayable: Boolean, +): HomeWidgetModel { + val content = widgetContent( + access, + folderAccessible, + bookCount, + playedCount = recent.size + (if (current != null) 1 else 0), + needsPlayed = true, + ) + val isLocalMode = when (access) { + is WidgetAccess.Library -> access.isLocalMode + is WidgetAccess.NoLibrary -> access.isLocalMode + else -> false + } + if (content != WidgetContent.READY) return HomeWidgetModel(content, isLocalMode, null, null, emptyList()) + + val total = totalTimeGivenSeconds(recent.map { it.currentTime.toDouble(DurationUnit.SECONDS) }) + val rank = RankCard(AltarCopy.epithet(total), "Total time given: " + formatListeningTime(total)) + + val shown = current ?: recent.first() + val chapter = if (current != null) { + liveChapterTitle + } else { + chapterTitleAt(shown.chapters, shown.currentTime.toDouble(DurationUnit.SECONDS)) + }?.takeIf { it.isNotBlank() } + val audible = state == PlaybackState.PLAYING || state == PlaybackState.BUFFERING + val button = when { + bookLoading || state == PlaybackState.LOADING -> CurrentButton.WAIT + current != null && audible -> CurrentButton.PAUSE + current != null -> CurrentButton.PLAY + !shownPlayable -> CurrentButton.OPEN + else -> CurrentButton.PLAY + } + val lives = recent.filter { it.id != shown.id }.take(MAX_LIVES).map { LifeTile(it.id, it.title) } + return HomeWidgetModel( + content = content, + isLocalMode = isLocalMode, + rank = rank, + current = CurrentCard(shown.id, shown.title, chapter, button), + lives = lives, + ) +} + +/** + * The reads for one update, for the library [access] chose. Locked, signed + * out and no library read nothing at all. A local library checks its folder + * grant before it reads any book. + */ +internal suspend fun loadHomeWidgetInputs(access: WidgetAccess, source: WidgetBookSource): HomeWidgetInputs { + val library = access as? WidgetAccess.Library ?: return HomeWidgetInputs(false, 0, emptyList()) + if (library.isLocalMode && !source.folderAccessible(library.libraryId)) { + return HomeWidgetInputs(false, 0, emptyList()) + } + return HomeWidgetInputs( + folderAccessible = true, + bookCount = source.countBooks(library.libraryId), + recent = source.recent(library.libraryId, RECENT_LIVES_LIMIT), + ) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt new file mode 100644 index 00000000..da65a2c1 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt @@ -0,0 +1,188 @@ +package com.ninelivesaudio.app.widget.home + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.glance.appwidget.updateAll +import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.isInActiveLibrary +import com.ninelivesaudio.app.entitlement.EffectiveSettingsRepository +import com.ninelivesaudio.app.service.PlaybackManager +import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.widget.HOME_CURRENT_COVER_PX +import com.ninelivesaudio.app.widget.HOME_LIFE_COVER_PX +import com.ninelivesaudio.app.widget.WidgetAccess +import com.ninelivesaudio.app.widget.WidgetBookSource +import com.ninelivesaudio.app.widget.WidgetContent +import com.ninelivesaudio.app.widget.WidgetCoverLoader +import com.ninelivesaudio.app.widget.WidgetPlayback +import com.ninelivesaudio.app.widget.widgetAccess +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +/** Cover art for one draw. One bitmap per cover, shared by every size Glance lays out. */ +internal data class HomeWidgetArt(val current: Bitmap?, val lives: Map) + +internal data class HomeWidgetSnapshot(val model: HomeWidgetModel, val art: HomeWidgetArt) + +/** + * What the Nine Lives widget shows, kept in process for Glance to draw. + * [refresh] runs on the refresher's pushes and when a widget session starts + * cold. It never triggers a push itself, so it cannot loop. + */ +@Singleton +class HomeWidgetStore @Inject constructor( + @ApplicationContext private val context: Context, + private val settingsManager: SettingsManager, + private val effectiveSettings: EffectiveSettingsRepository, + private val source: WidgetBookSource, + private val playbackManager: PlaybackManager, + private val audioBookRepository: AudioBookRepository, + private val widgetPlayback: WidgetPlayback, + private val coverLoader: WidgetCoverLoader, +) { + private val _state = MutableStateFlow(null) + internal val state: StateFlow = _state.asStateFlow() + + private val lock = Mutex() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var artJob: Job? = null + private var generation = 0L + + /** + * Reads everything again. The access check comes first, and a locked, + * signed out or empty widget reads no books and no covers. Covers on disk + * draw at once. Missing ones download after, bounded and cancelled by the + * next refresh, and redraw the widget only if one arrived. + */ + suspend fun refresh() = withContext(Dispatchers.IO) { + lock.withLock { + artJob?.cancel() + val mine = ++generation + withTimeoutOrNull(5.seconds) { settingsManager.isLoaded.first { it } } + val settings = settingsManager.currentSettings + val access = widgetAccess( + isUnlocked = effectiveSettings.isUnlockedNow, + settings = settings, + hasAuthToken = settingsManager.hasAuthToken.value, + ) + val inputs = loadHomeWidgetInputs(access, source) + val library = access as? WidgetAccess.Library + + // The player's book, else the one the app will restore, never another library's. + val playerBook = playbackManager.currentBook.value + ?.takeIf { library != null && it.libraryId == library.libraryId && it.isInActiveLibrary(settings) } + val current = playerBook ?: if (library != null && inputs.folderAccessible) { + settingsManager.getCurrentPlaybackBookId() + ?.let { audioBookRepository.getById(it) } + ?.takeIf { it.libraryId == library.libraryId && it.isInActiveLibrary(settings) && !it.isArchived } + } else { + null + } + val liveChapter = if (playerBook != null) { + playbackManager.currentChapter.value?.title + } else { + current?.let { chapterTitleAt(it.chapters, it.currentTime.toDouble(DurationUnit.SECONDS)) } + } + val shownPlayable = if (current == null && library != null) { + inputs.recent.firstOrNull()?.let { widgetPlayback.isPlayable(it, settle = false) } ?: false + } else { + true + } + val model = buildHomeWidgetModel( + access = access, + folderAccessible = inputs.folderAccessible, + bookCount = inputs.bookCount, + recent = inputs.recent, + current = current, + liveChapterTitle = liveChapter, + state = playbackManager.playbackState.value, + bookLoading = playbackManager.loadingBook.value != null, + shownPlayable = shownPlayable, + ) + if (model.content != WidgetContent.READY) { + publish(HomeWidgetSnapshot(model, HomeWidgetArt(null, emptyMap()))) + return@withLock + } + + val byId = (inputs.recent + listOfNotNull(current)).associateBy { it.id } + val wanted = buildList { + model.current?.let { c -> byId[c.bookId]?.let { add(it to HOME_CURRENT_COVER_PX) } } + model.lives.forEach { l -> byId[l.bookId]?.let { add(it to HOME_LIFE_COVER_PX) } } + } + val cached = wanted.associate { (book, px) -> book.id to coverLoader.cached(book, px) } + publish(HomeWidgetSnapshot(model, art(model, cached))) + + val missing = wanted.filter { (book, _) -> cached[book.id] == null && book.effectiveCoverPath != null } + if (missing.isNotEmpty()) { + artJob = scope.launch { fetchMissing(mine, model, cached, missing) } + } + } + } + + private suspend fun fetchMissing( + mine: Long, + model: HomeWidgetModel, + cached: Map, + missing: List>, + ) { + val fetched = withTimeoutOrNull(COVER_FETCH_DEADLINE) { + missing.map { (book, px) -> + async { + try { + book.id to coverLoader.load(book, px) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "cover failed ${book.id}: ${e.message}") + book.id to null + } + } + }.awaitAll() + }.orEmpty().filter { it.second != null } + if (fetched.isEmpty()) return + lock.withLock { + if (generation != mine) return + publish(HomeWidgetSnapshot(model, art(model, cached + fetched))) + } + // A running Glance session redraws from the state flow. One that has + // closed is started again, and its cold start finds the covers on disk. + runCatching { NineLivesHomeWidget().updateAll(context) } + .onFailure { Log.w(TAG, "redraw after covers failed: ${it.message}") } + } + + private fun art(model: HomeWidgetModel, covers: Map) = HomeWidgetArt( + current = model.current?.let { covers[it.bookId] }, + lives = model.lives.mapNotNull { l -> covers[l.bookId]?.let { l.bookId to it } }.toMap(), + ) + + private fun publish(snapshot: HomeWidgetSnapshot) { + Log.d(TAG, "state ${snapshot.model.content} current=${snapshot.model.current?.button} lives=${snapshot.model.lives.size} art=${snapshot.art.current != null}/${snapshot.art.lives.size}") + _state.value = snapshot + } + + private companion object { + const val TAG = "NineLivesHomeWidget" + val COVER_FETCH_DEADLINE = 8.seconds + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt new file mode 100644 index 00000000..72799865 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -0,0 +1,358 @@ +package com.ninelivesaudio.app.widget.home + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +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.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.ninelivesaudio.app.R +import com.ninelivesaudio.app.ui.theme.NoirColors +import com.ninelivesaudio.app.widget.WidgetContent +import com.ninelivesaudio.app.widget.WidgetCopy +import com.ninelivesaudio.app.widget.WidgetLaunch +import com.ninelivesaudio.app.widget.widgetActionIntent +import com.ninelivesaudio.app.widget.widgetMainIntent +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * Nine Lives, the large home screen widget: Home's rank card, the current + * book with play and pause, and the next Lives. Always the Noir palette + * (plan Design delta 10). The model and covers come from [HomeWidgetStore], + * so drawing never reads the database and never touches the network. + * + * Every element that can be tapped in some state is given its action on + * every draw. A launcher can reapply a new draw onto the old views, and a + * view left without an action keeps the one it had. + */ +class NineLivesHomeWidget : GlanceAppWidget() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Deps { + fun homeWidgetStore(): HomeWidgetStore + } + + override val sizeMode: SizeMode = + SizeMode.Responsive(HOME_WIDGET_SIZES.map { (w, h) -> DpSize(w.dp, h.dp) }.toSet()) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val store = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).homeWidgetStore() + if (store.state.value == null) store.refresh() + provideContent { + val snapshot by store.state.collectAsState() + HomeWidgetFace(snapshot) + } + } +} + +private val textPrimary = ColorProvider(NoirColors.archiveTextPrimary) +private val textSecondary = ColorProvider(NoirColors.archiveTextSecondary) +private val textMuted = ColorProvider(NoirColors.archiveTextMuted) +private val gold = ColorProvider(NoirColors.goldFilament) + +@Composable +private fun HomeWidgetFace(snapshot: HomeWidgetSnapshot?) { + val context = LocalContext.current + val size = LocalSize.current + val layout = homeLayoutFor(size.width.value, size.height.value) + val model = snapshot?.model + val rootLaunch = if (model?.content == WidgetContent.LOCKED) WidgetLaunch.OpenUnlock else WidgetLaunch.OpenApp + val rootLabel = when (model?.content) { + WidgetContent.LOCKED -> WidgetCopy.CD_LOCKED + null, WidgetContent.READY -> WidgetCopy.CD_OPEN_APP + else -> WidgetCopy.emptyLine(model.content, model.isLocalMode) ?: WidgetCopy.CD_OPEN_APP + } + Box( + modifier = GlanceModifier + .fillMaxSize() + .appWidgetBackground() + .background(ImageProvider(R.drawable.widget_frame)) + .cornerRadius(16.dp) + .clickable(actionStartActivity(widgetMainIntent(context, rootLaunch))) + .semantics { contentDescription = rootLabel } + .padding(if (layout == HomeLayout.CURRENT_ONLY) 10.dp else 12.dp), + contentAlignment = Alignment.Center, + ) { + when { + model == null -> Message(line = null, locked = false, layout = layout) + model.content == WidgetContent.LOCKED -> Message(WidgetCopy.HOME_LOCKED, locked = true, layout = layout) + model.content != WidgetContent.READY -> Message(WidgetCopy.emptyLine(model.content, model.isLocalMode), locked = false, layout = layout) + else -> Ready(model, snapshot.art, layout, size.width.value, isTallCurrentOnly(size.width.value, size.height.value)) + } + } +} + +/** Locked, signed out, empty: the eye and one calm line. Never a book, a cover or a rank. */ +@Composable +private fun Message(line: String?, locked: Boolean, layout: HomeLayout) { + Column( + modifier = GlanceModifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + provider = ImageProvider(R.drawable.widget_eye), + contentDescription = null, + modifier = GlanceModifier.size(if (layout == HomeLayout.CURRENT_ONLY) 40.dp else 56.dp), + ) + if (line != null) { + Spacer(GlanceModifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (locked) { + Image( + provider = ImageProvider(R.drawable.widget_lock), + contentDescription = null, + modifier = GlanceModifier.size(16.dp), + ) + Spacer(GlanceModifier.width(6.dp)) + } + Text( + text = line, + maxLines = 3, + style = TextStyle(color = textSecondary, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), + ) + } + } + if (locked && layout == HomeLayout.FULL) { + // An altar waiting for its Lives: empty sleeves, generic art only. + Spacer(GlanceModifier.height(14.dp)) + Row { + repeat(3) { i -> + if (i > 0) Spacer(GlanceModifier.width(10.dp)) + Image( + provider = ImageProvider(R.drawable.widget_sleeve_empty), + contentDescription = null, + modifier = GlanceModifier.size(40.dp), + ) + } + } + } + } +} + +/** + * The rank card anchors to the top and the Lives to the bottom, with the + * current book between them, and equal weighted gaps take whatever height the + * launcher gives. Without Lives the current book sits in the middle of the + * space under the rank card. Only the one row face stays centered. + */ +@Composable +private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout, widthDp: Float, tall: Boolean) { + val context = LocalContext.current + if (layout == HomeLayout.CURRENT_ONLY) { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = if (tall) Alignment.Top else Alignment.CenterVertically, + ) { + model.current?.let { + if (tall) CurrentBookStacked(it, art.current) else CurrentBook(it, art.current, compact = true) + } + } + return + } + Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Top) { + model.rank?.let { RankSlab(it, actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp))) } + Spacer(GlanceModifier.defaultWeight()) + model.current?.let { CurrentBook(it, art.current, compact = false) } + Spacer(GlanceModifier.defaultWeight()) + val slots = livesSlotsFor(widthDp) + val lives = model.lives.take(slots) + if (layout == HomeLayout.FULL && lives.isNotEmpty()) { + Image( + provider = ImageProvider(R.drawable.widget_home_rule), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = GlanceModifier.fillMaxWidth().height(1.dp), + ) + Spacer(GlanceModifier.height(LIFE_GAP_DP.dp)) + // Every slot is laid out, the first and last flush with the edges and + // equal weighted gaps between, so the tiles span the full width at any + // real width. A slot without a Life stays empty, as on Home's grid. + val tile = lifeTileDp(widthDp, slots).toInt() + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + repeat(slots) { i -> + if (i > 0) Spacer(GlanceModifier.defaultWeight()) + val life = lives.getOrNull(i) + if (life != null) { + Cover( + bitmap = art.lives[life.bookId], + label = life.title, + sizeDp = tile, + action = openBook(context, life.bookId), + ) + } else { + Spacer(GlanceModifier.size(tile.dp)) + } + } + } + } + } +} + +/** Home's altar card, small: the eye, the epithet, and the total time given. Opens Home. */ +@Composable +private fun RankSlab(rank: RankCard, action: Action) { + Row( + modifier = GlanceModifier + .fillMaxWidth() + .background(ImageProvider(R.drawable.widget_home_slab)) + .cornerRadius(12.dp) + .clickable(action) + .semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + provider = ImageProvider(R.drawable.widget_eye), + contentDescription = null, + modifier = GlanceModifier.size(40.dp), + ) + Spacer(GlanceModifier.width(10.dp)) + Column { + Text( + text = rank.epithet, + maxLines = 1, + style = TextStyle(color = textMuted, fontSize = 10.sp, fontWeight = FontWeight.Bold), + ) + Spacer(GlanceModifier.height(2.dp)) + Text( + text = rank.totalTimeText, + maxLines = 1, + style = TextStyle(color = gold, fontSize = 12.sp, fontWeight = FontWeight.Medium), + ) + } + } +} + +@Composable +private fun CurrentBook(current: CurrentCard, cover: android.graphics.Bitmap?, compact: Boolean) { + val context = LocalContext.current + val open = openBook(context, current.bookId) + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Cover(bitmap = cover, label = current.title, sizeDp = if (compact) 48 else 64, action = open) + Spacer(GlanceModifier.width(if (compact) 8.dp else 10.dp)) + Column( + modifier = GlanceModifier.defaultWeight().clickable(open), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = current.title, + maxLines = 2, + style = TextStyle(color = textPrimary, fontSize = if (compact) 13.sp else 14.sp, fontWeight = FontWeight.Medium), + ) + current.chapterTitle?.let { + Spacer(GlanceModifier.height(2.dp)) + Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 12.sp)) + } + } + Spacer(GlanceModifier.width(if (compact) 6.dp else 8.dp)) + PlayButton(current, open, if (compact) 40 else 44) + } +} + +/** A 2x2: cover and play side by side at the top, the title and chapter anchored to the bottom. */ +@Composable +private fun CurrentBookStacked(current: CurrentCard, cover: android.graphics.Bitmap?) { + val context = LocalContext.current + val open = openBook(context, current.bookId) + Column(modifier = GlanceModifier.fillMaxSize()) { + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Cover(bitmap = cover, label = current.title, sizeDp = 72, action = open) + Spacer(GlanceModifier.defaultWeight()) + PlayButton(current, open, 44) + } + Spacer(GlanceModifier.height(10.dp)) + Spacer(GlanceModifier.defaultWeight()) + Column(modifier = GlanceModifier.fillMaxWidth().clickable(open)) { + Text( + text = current.title, + maxLines = 2, + style = TextStyle(color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium), + ) + current.chapterTitle?.let { + Spacer(GlanceModifier.height(2.dp)) + Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 12.sp)) + } + } + } +} + +@Composable +private fun PlayButton(current: CurrentCard, open: Action, sizeDp: Int) { + val context = LocalContext.current + val (icon, label, action) = when (current.button) { + CurrentButton.PLAY -> Triple(R.drawable.widget_home_play, WidgetCopy.CD_PLAY, toggle(context, current.bookId)) + CurrentButton.PAUSE -> Triple(R.drawable.widget_home_pause, WidgetCopy.CD_PAUSE, toggle(context, current.bookId)) + // Loading: the app ignores a tap now, and so does this one (the plan says IGNORE), + // but the button still carries an action so it never keeps an old one. + CurrentButton.WAIT -> Triple(R.drawable.widget_home_wait, WidgetCopy.CD_PLAY, toggle(context, current.bookId)) + CurrentButton.OPEN -> Triple(R.drawable.widget_home_open, current.title, open) + } + Image( + provider = ImageProvider(icon), + contentDescription = label, + modifier = GlanceModifier.size(sizeDp.dp).clickable(action), + ) +} + +/** + * A square cover, fitted whole: the bitmap is already square with its + * corners rounded on the widget's own surface colour (WidgetCoverLoader), so + * nothing is cropped and the corners match on every API level. + */ +@Composable +private fun Cover(bitmap: android.graphics.Bitmap?, label: String, sizeDp: Int, action: Action) { + Image( + provider = bitmap?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.widget_sleeve_empty), + contentDescription = label, + contentScale = ContentScale.Fit, + modifier = GlanceModifier.size(sizeDp.dp).clickable(action), + ) +} + +/** Open its page goes through the trampoline, which drops a stale tap from another library. */ +private fun openBook(context: Context, bookId: String): Action = + actionStartActivity(widgetActionIntent(context, WidgetLaunch.OpenBook(bookId))) + +/** Play or pause, through the trampoline that holds until the service is in the foreground. */ +private fun toggle(context: Context, bookId: String): Action = + actionStartActivity(widgetActionIntent(context, WidgetLaunch.TogglePlay(bookId))) diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt new file mode 100644 index 00000000..93895f3f --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt @@ -0,0 +1,52 @@ +package com.ninelivesaudio.app.widget.home + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.util.Log +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import com.ninelivesaudio.app.entitlement.EntitlementRepository +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * The Nine Lives widget's provider. Redraws come from the app's own events + * through `WidgetRefresher`. The system's hourly update is a backstop: it + * re-resolves entitlement first, so a trial that ended with the app closed + * relocks here, the same as the Binder. + */ +class NineLivesHomeWidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget = NineLivesHomeWidget() + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Deps { + fun homeWidgetStore(): HomeWidgetStore + fun entitlementRepository(): EntitlementRepository + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + // Glance's own update holds the broadcast and runs the session, which + // draws from the store and redraws when the refresh below lands. + super.onUpdate(context, appWidgetManager, appWidgetIds) + val deps = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java) + backstopScope.launch { + runCatching { + deps.entitlementRepository().refresh() + deps.homeWidgetStore().refresh() + }.onFailure { Log.w(TAG, "backstop refresh failed: ${it.message}") } + } + } + + private companion object { + const val TAG = "NineLivesHomeWidget" + val backstopScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } +} diff --git a/app/src/main/res/drawable/widget_home_open.xml b/app/src/main/res/drawable/widget_home_open.xml new file mode 100644 index 00000000..6f64ae3b --- /dev/null +++ b/app/src/main/res/drawable/widget_home_open.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_home_pause.xml b/app/src/main/res/drawable/widget_home_pause.xml new file mode 100644 index 00000000..19c70a8e --- /dev/null +++ b/app/src/main/res/drawable/widget_home_pause.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_home_play.xml b/app/src/main/res/drawable/widget_home_play.xml new file mode 100644 index 00000000..cc878f1a --- /dev/null +++ b/app/src/main/res/drawable/widget_home_play.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_home_rule.xml b/app/src/main/res/drawable/widget_home_rule.xml new file mode 100644 index 00000000..b2e454a6 --- /dev/null +++ b/app/src/main/res/drawable/widget_home_rule.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_home_slab.xml b/app/src/main/res/drawable/widget_home_slab.xml new file mode 100644 index 00000000..48177e98 --- /dev/null +++ b/app/src/main/res/drawable/widget_home_slab.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_home_wait.xml b/app/src/main/res/drawable/widget_home_wait.xml new file mode 100644 index 00000000..00bbb910 --- /dev/null +++ b/app/src/main/res/drawable/widget_home_wait.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_home_preview.xml b/app/src/main/res/layout/widget_home_preview.xml new file mode 100644 index 00000000..33e28e8e --- /dev/null +++ b/app/src/main/res/layout/widget_home_preview.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afcac2bf..1290165f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,4 +12,6 @@ Binder Flip through your books like an old CD binder + Nine Lives + Your rank, your current book, and your next lives diff --git a/app/src/main/res/xml/widget_home_info.xml b/app/src/main/res/xml/widget_home_info.xml new file mode 100644 index 00000000..973179f0 --- /dev/null +++ b/app/src/main/res/xml/widget_home_info.xml @@ -0,0 +1,17 @@ + + + diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt new file mode 100644 index 00000000..93da399c --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt @@ -0,0 +1,19 @@ +package com.ninelivesaudio.app.ui.home + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ListeningTimeFormatTest { + + @Test fun `listening time reads the way the altar card always has`() { + assertEquals("< 1m", formatListeningTime(30.0)) + assertEquals("45m", formatListeningTime(45 * 60.0)) + assertEquals("3h 22m", formatListeningTime(3 * 3600.0 + 22 * 60.0)) + } + + @Test fun `total time given is the sum over the recent lives`() { + assertEquals(5400.0, totalTimeGivenSeconds(listOf(1800.0, 3600.0)), 0.0) + assertEquals(0.0, totalTimeGivenSeconds(emptyList()), 0.0) + assertEquals(9, RECENT_LIVES_LIMIT) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt b/app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt new file mode 100644 index 00000000..ebdfad84 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt @@ -0,0 +1,25 @@ +package com.ninelivesaudio.app.widget + +import com.ninelivesaudio.app.domain.model.AudioBook + +/** A [WidgetBookSource] that records which reads ran, shared by the widget tests. */ +internal class FakeSource( + private val folderOk: Boolean = true, + private val books: List = emptyList(), +) : WidgetBookSource { + val calls = mutableListOf() + var lastLibraryId: String? = null + + override suspend fun folderAccessible(libraryId: String): Boolean { + calls += "folderAccessible"; lastLibraryId = libraryId; return folderOk + } + override suspend fun countBooks(libraryId: String): Int { + calls += "countBooks"; lastLibraryId = libraryId; return books.size + } + override suspend fun shelf(libraryId: String): List { + calls += "shelf"; lastLibraryId = libraryId; return books + } + override suspend fun recent(libraryId: String, limit: Int): List { + calls += "recent"; lastLibraryId = libraryId; return books.take(limit) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt index 3bb5a109..6b4c3515 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt @@ -10,27 +10,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -internal class FakeSource( - private val folderOk: Boolean = true, - private val books: List = emptyList(), -) : WidgetBookSource { - val calls = mutableListOf() - var lastLibraryId: String? = null - - override suspend fun folderAccessible(libraryId: String): Boolean { - calls += "folderAccessible"; lastLibraryId = libraryId; return folderOk - } - override suspend fun countBooks(libraryId: String): Int { - calls += "countBooks"; lastLibraryId = libraryId; return books.size - } - override suspend fun shelf(libraryId: String): List { - calls += "shelf"; lastLibraryId = libraryId; return books - } - override suspend fun recent(libraryId: String, limit: Int): List { - calls += "recent"; lastLibraryId = libraryId; return books.take(limit) - } -} - class WidgetBookSourceTest { @Test fun `a locked binder never touches the library`() = runBlocking { diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt index 0a02f307..6afaeed3 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt @@ -7,13 +7,14 @@ import org.junit.Test class WidgetLaunchTest { @Test fun `every launch survives the intent extras`() { - for (l in listOf(WidgetLaunch.OpenApp, WidgetLaunch.OpenBook("b1"), WidgetLaunch.PlayBook("b1"), WidgetLaunch.OpenUnlock)) + for (l in listOf(WidgetLaunch.OpenApp, WidgetLaunch.OpenBook("b1"), WidgetLaunch.PlayBook("b1"), WidgetLaunch.OpenUnlock, WidgetLaunch.TogglePlay("b1"))) assertEquals(l, parseWidgetLaunch(l.actionName(), l.bookIdOrNull)) } @Test fun `a book launch without a book is no launch`() { assertNull(parseWidgetLaunch("open_book", null)) assertNull(parseWidgetLaunch("play_book", " ")) + assertNull(parseWidgetLaunch("toggle_play", null)) assertNull(parseWidgetLaunch("something_else", "b1")) assertNull(parseWidgetLaunch(null, null)) } @@ -26,5 +27,14 @@ class WidgetLaunchTest { assertEquals(WidgetRoute.Book("b1"), widgetRoute(WidgetLaunch.OpenBook("b1"))) assertEquals(WidgetRoute.Unlock, widgetRoute(WidgetLaunch.OpenUnlock)) assertNull(widgetRoute(WidgetLaunch.PlayBook("b1"))) + assertNull(widgetRoute(WidgetLaunch.TogglePlay("b1"))) + } + + @Test fun `a widget tap for another book opens that book even when a book page is on top`() { + // Single top matched the route pattern, so book B reused book A's page. + assertEquals(true, opensNewBookPage(topRoute = "book_detail/{bookId}", topBookId = "a", bookId = "b")) + assertEquals(false, opensNewBookPage(topRoute = "book_detail/{bookId}", topBookId = "b", bookId = "b")) + assertEquals(true, opensNewBookPage(topRoute = "home", topBookId = null, bookId = "b")) + assertEquals(true, opensNewBookPage(topRoute = null, topBookId = null, bookId = "b")) } } diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt index 67ae8925..35c758dc 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt @@ -38,14 +38,37 @@ class WidgetPlaybackTest { assertEquals(WidgetPlayPlan.LOAD_BOOK, binderPlayPlan(true, "b2", "b1", PlaybackState.PAUSED, false, playable = true)) } + @Test fun `a locked play button sends the tap to the unlock screen`() { + assertEquals(WidgetPlayPlan.OPEN_UNLOCK, togglePlan(false, "b1", PlaybackState.PLAYING, false, "b1", true)) + } + @Test fun `play pause toggles the retained book like the mini player`() { assertEquals(WidgetPlayPlan.PAUSE, togglePlan(true, "b1", PlaybackState.BUFFERING, false, "b1", true)) assertEquals(WidgetPlayPlan.RESUME_CURRENT, togglePlan(true, "b1", PlaybackState.STOPPED, false, "b1", true)) } + @Test fun `a stale shown book from another library is ignored even while playing`() { + assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, "new", PlaybackState.PLAYING, false, shownBookId = "old", shownPlayable = true, shownInActiveLibrary = false)) + assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, "new", PlaybackState.PAUSED, false, shownBookId = "old", shownPlayable = true, shownInActiveLibrary = false)) + assertEquals(WidgetPlayPlan.PAUSE, togglePlan(true, "b1", PlaybackState.PLAYING, false, shownBookId = "b1", shownPlayable = true, shownInActiveLibrary = true)) + } + + @Test fun `a pause tap never runs the playability probe`() = runBlocking { + var probed = 0 + val probe: suspend () -> Boolean = { probed++; awaitCancellation() } + assertEquals(WidgetPlayPlan.PAUSE, togglePlanLazy(true, "b1", PlaybackState.PLAYING, false, "b1", true) { probe() }) + assertEquals(WidgetPlayPlan.PAUSE, togglePlanLazy(true, "b1", PlaybackState.BUFFERING, false, "b1", true) { probe() }) + assertEquals(WidgetPlayPlan.IGNORE, togglePlanLazy(true, "new", PlaybackState.PLAYING, false, "old", false) { probe() }) + assertEquals(0, probed) + var loadProbed = 0 + assertEquals(WidgetPlayPlan.LOAD_BOOK, togglePlanLazy(true, null, PlaybackState.STOPPED, false, "b3", true) { loadProbed++; true }) + assertEquals(1, loadProbed) + } + @Test fun `with nothing retained play loads the book the widget shows`() { assertEquals(WidgetPlayPlan.LOAD_BOOK, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = true)) - assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = false)) + // Like a Binder cover: a book that cannot play right now opens its page, which says why. + assertEquals(WidgetPlayPlan.OPEN_BOOK_PAGE, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = false)) assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = null, shownPlayable = true)) } diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt index 340e0182..12938922 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt @@ -41,6 +41,14 @@ class WidgetRefreshTriggersTest { assertEquals(setOf(WidgetTarget.HOME), widgetRefreshTargets(t0, next)) } + @Test fun `a load starting or ending redraws only the large widget`() { + // A failed load leaves the same book, not audible: without this the + // large widget kept the dimmed loading button until some other event. + val loading = t0.copy(isLoading = true) + assertEquals(setOf(WidgetTarget.HOME), widgetRefreshTargets(t0, loading)) + assertEquals(setOf(WidgetTarget.HOME), widgetRefreshTargets(loading, t0)) + } + @Test fun `nothing changed means no redraw`() { assertEquals(emptySet(), widgetRefreshTargets(t0, t0.copy())) } diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt new file mode 100644 index 00000000..fa2ac0be --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -0,0 +1,68 @@ +package com.ninelivesaudio.app.widget.home + +import com.ninelivesaudio.app.widget.WIDGET_BITMAP_BUDGET_BYTES +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeWidgetLayoutTest { + + @Test fun `smaller sizes drop the lives first, then the rank card`() { + assertEquals(HomeLayout.FULL, homeLayoutFor(320f, 270f)) + assertEquals(HomeLayout.RANK_AND_CURRENT, homeLayoutFor(320f, 259f)) + assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(320f, 179f)) + assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(249f, 400f)) + } + + @Test fun `every responsive bucket maps to a layout that keeps play`() { + assertEquals( + listOf(HomeLayout.CURRENT_ONLY, HomeLayout.CURRENT_ONLY, HomeLayout.RANK_AND_CURRENT, HomeLayout.FULL, HomeLayout.FULL), + HOME_WIDGET_SIZES.map { (w, h) -> homeLayoutFor(w, h) }, + ) + } + + @Test fun `the smallest bucket is the resize floor and keeps play`() { + assertEquals(160f to 80f, HOME_WIDGET_SIZES.first()) + assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(160f, 80f)) + } + + @Test fun `wider widgets show four lives`() { + assertEquals(3, livesSlotsFor(299f)) + assertEquals(4, livesSlotsFor(300f)) + } + + @Test fun `the four lives breakpoint is a responsive bucket, so a 300dp widget gets four`() { + // Glance hands the layout the bucket's size, not the real one, so the + // J3 breakpoint only holds if some bucket starts exactly there. + assertTrue(HOME_WIDGET_SIZES.any { (w, h) -> w == 300f && homeLayoutFor(w, h) == HomeLayout.FULL }) + assertEquals(3, HOME_WIDGET_SIZES.filter { homeLayoutFor(it.first, it.second) == HomeLayout.FULL }.minOf { livesSlotsFor(it.first) }) + } + + @Test fun `the large widget stays inside the bitmap budget at every size`() { + assertTrue(homeWorstCaseBitmapBytes() <= WIDGET_BITMAP_BUDGET_BYTES) + // Lives only count where they are drawn. + assertEquals(192 * 192 * 2, homeBucketBitmapBytes(250f, 180f)) + assertEquals(192 * 192 * 2 + 4 * 128 * 128 * 2, homeBucketBitmapBytes(300f, 260f)) + } + + @Test fun `a 2x2 stacks the current book, a short one keeps it in a row`() { + assertTrue(isTallCurrentOnly(160f, 180f)) + assertTrue(!isTallCurrentOnly(160f, 80f)) + assertTrue(!isTallCurrentOnly(250f, 180f)) + } + + @Test fun `the lives share the row width evenly, square, with 10dp gaps`() { + // 12dp padding a side, so the tiles and their gaps span the width inside it. + assertEquals((300f - 24f - 3 * 10f) / 4, lifeTileDp(300f, 4), 0.01f) + assertEquals((250f - 24f - 2 * 10f) / 3, lifeTileDp(250f, 3), 0.01f) + // A very wide widget stops growing the tiles, so the column still fits its height. + assertEquals(MAX_LIFE_TILE_DP, lifeTileDp(600f, 3), 0.01f) + } + + @Test fun `the full face fits its smallest height with the tiles at their size`() { + // Rank slab 56, current book 64, rule 1 plus its 10dp gap, the tiles, 12dp padding a side. + for ((w, h) in HOME_WIDGET_SIZES.filter { homeLayoutFor(it.first, it.second) == HomeLayout.FULL }) { + assertTrue("$w x $h", 56f + 64f + 11f + lifeTileDp(w, livesSlotsFor(w)) + 24f <= h) + } + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt new file mode 100644 index 00000000..12eaa58b --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt @@ -0,0 +1,140 @@ +package com.ninelivesaudio.app.widget.home + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.Chapter +import com.ninelivesaudio.app.service.PlaybackState +import com.ninelivesaudio.app.ui.home.AltarCopy +import com.ninelivesaudio.app.ui.home.formatListeningTime +import com.ninelivesaudio.app.widget.FakeSource +import com.ninelivesaudio.app.widget.WidgetAccess +import com.ninelivesaudio.app.widget.WidgetContent +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class HomeWidgetModelTest { + + private val books = (1..5).map { i -> AudioBook(id = "b$i", title = "Book $i", currentTime = (i * 600).seconds) } + private val ch = listOf(Chapter(0, 0.0, 300.0, "One"), Chapter(1, 300.0, 900.0, "Two")) + private val lib = WidgetAccess.Library("lib_s", false) + + private fun ready( + current: AudioBook?, + liveChapter: String? = null, + state: PlaybackState = PlaybackState.STOPPED, + loading: Boolean = false, + playable: Boolean = true, + ) = buildHomeWidgetModel(lib, true, 5, books, current, liveChapter, state, loading, playable) + + @Test fun `a locked large widget reads nothing and shows nothing personal`() = runBlocking { + val src = FakeSource(books = books) + assertEquals(HomeWidgetInputs(false, 0, emptyList()), loadHomeWidgetInputs(WidgetAccess.Locked, src)) + assertEquals(emptyList(), src.calls) + val m = buildHomeWidgetModel(WidgetAccess.Locked, false, 0, emptyList(), null, null, PlaybackState.STOPPED, false, false) + assertEquals(WidgetContent.LOCKED, m.content) + assertNull(m.rank) + assertNull(m.current) + assertTrue(m.lives.isEmpty()) + } + + @Test fun `a locked widget shows nothing even when handed books`() { + val m = buildHomeWidgetModel(WidgetAccess.Locked, true, 5, books, books[0], "Live", PlaybackState.PLAYING, false, true) + assertEquals(WidgetContent.LOCKED, m.content) + assertNull(m.rank) + assertNull(m.current) + assertTrue(m.lives.isEmpty()) + } + + @Test fun `signed out and no library read nothing`() = runBlocking { + val src = FakeSource(books = books) + loadHomeWidgetInputs(WidgetAccess.SignedOut, src) + loadHomeWidgetInputs(WidgetAccess.NoLibrary(isLocalMode = true), src) + assertEquals(emptyList(), src.calls) + } + + @Test fun `a local library with its folder gone stops before reading books`() = runBlocking { + val src = FakeSource(folderOk = false, books = books) + val inputs = loadHomeWidgetInputs(WidgetAccess.Library("lib_l", true), src) + assertEquals(listOf("folderAccessible"), src.calls) + val m = buildHomeWidgetModel(WidgetAccess.Library("lib_l", true), inputs.folderAccessible, inputs.bookCount, inputs.recent, null, null, PlaybackState.STOPPED, false, true) + assertEquals(WidgetContent.FOLDER_LOST, m.content) + assertNull(m.current) + } + + @Test fun `a server library reads its count and the same nine recent books as Home`() = runBlocking { + val many = (1..12).map { AudioBook(id = "m$it") } + val src = FakeSource(books = many) + val inputs = loadHomeWidgetInputs(lib, src) + assertEquals(listOf("countBooks", "recent"), src.calls) + assertEquals("lib_s", src.lastLibraryId) + assertEquals(9, inputs.recent.size) + assertEquals(12, inputs.bookCount) + assertTrue(inputs.folderAccessible) + } + + @Test fun `rank card matches Home's altar for the same books`() { + val m = ready(current = null) + val total = books.sumOf { it.currentTime.inWholeSeconds.toDouble() } + assertEquals(AltarCopy.epithet(total), m.rank!!.epithet) + assertEquals("Total time given: " + formatListeningTime(total), m.rank!!.totalTimeText) + } + + @Test fun `the current book is the one in the player, else the most recent`() { + assertEquals("b3", ready(current = books[2]).current!!.bookId) + assertEquals("b1", ready(current = null).current!!.bookId) + } + + @Test fun `the next lives skip the current book and stop at four`() { + assertEquals(listOf("b1", "b2", "b4", "b5"), ready(current = books[2]).lives.map { it.bookId }) + assertEquals(listOf("b2", "b3", "b4", "b5"), ready(current = null).lives.map { it.bookId }) + } + + @Test fun `chapter comes from the player when live, else from the saved position`() { + assertEquals("Live", ready(current = books[0], liveChapter = "Live").current!!.chapterTitle) + val cold = buildHomeWidgetModel(lib, true, 5, listOf(books[0].copy(chapters = ch, currentTime = 400.seconds)), null, null, PlaybackState.STOPPED, false, true) + assertEquals("Two", cold.current!!.chapterTitle) + assertNull(ready(current = books[0], liveChapter = " ").current!!.chapterTitle) + } + + @Test fun `chapter lookup handles edges`() { + assertNull(chapterTitleAt(emptyList(), 10.0)) + assertEquals("One", chapterTitleAt(ch, 0.0)) + assertEquals("Two", chapterTitleAt(ch, 300.0)) + assertEquals("Two", chapterTitleAt(ch, 5_000.0)) + assertEquals("One", chapterTitleAt(ch, -5.0)) + } + + @Test fun `the button follows the player and never offers play during a load`() { + assertEquals(CurrentButton.WAIT, ready(current = books[0], state = PlaybackState.PAUSED, loading = true).current!!.button) + assertEquals(CurrentButton.WAIT, ready(current = books[0], state = PlaybackState.LOADING).current!!.button) + assertEquals(CurrentButton.PAUSE, ready(current = books[0], state = PlaybackState.PLAYING).current!!.button) + assertEquals(CurrentButton.PAUSE, ready(current = books[0], state = PlaybackState.BUFFERING).current!!.button) + assertEquals(CurrentButton.PLAY, ready(current = books[0], state = PlaybackState.STOPPED).current!!.button) + assertEquals(CurrentButton.PLAY, ready(current = books[0], state = PlaybackState.PAUSED, playable = false).current!!.button) + assertEquals(CurrentButton.OPEN, ready(current = null, playable = false).current!!.button) + assertEquals(CurrentButton.PLAY, ready(current = null, playable = true).current!!.button) + } + + @Test fun `a book in the player with no saved progress yet still shows`() { + val m = buildHomeWidgetModel(lib, true, 5, emptyList(), books[0], null, PlaybackState.PAUSED, false, true) + assertEquals(WidgetContent.READY, m.content) + assertEquals("b1", m.current!!.bookId) + assertTrue(m.lives.isEmpty()) + } + + @Test fun `books but nothing played yet is its own calm state`() { + val m = buildHomeWidgetModel(lib, true, 5, emptyList(), null, null, PlaybackState.STOPPED, false, true) + assertEquals(WidgetContent.NOTHING_PLAYED, m.content) + assertNull(m.current) + assertNull(m.rank) + } + + @Test fun `an empty library shows no books, never an old rank`() { + val m = buildHomeWidgetModel(lib, true, 0, emptyList(), null, null, PlaybackState.STOPPED, false, true) + assertEquals(WidgetContent.NO_BOOKS, m.content) + assertNull(m.rank) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf9d9f5e..87717b77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ espressoCore = "3.6.1" ksp = "2.2.10-2.0.2" coroutines = "1.10.2" work = "2.9.1" +glance = "1.1.1" # Play Billing. Floor is 8.0 for any update submitted after 2026-08-31, so # integrate on current and skip the migration entirely. billing = "9.1.0" @@ -96,6 +97,7 @@ kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-co # WorkManager (background downloads) androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } # Play Billing (one-time unlock) billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }