Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
- The one `PlaybackRepository` is built lazily. Cast state waits in `DeferredCastSessionState` until it exists, so widget, worker and notification starts never build playback on the main thread.
- The container gives core modules ports instead of reverse dependencies: `feedTelemetry` for feed events, `rolloutSwitches`, and a `DownloadServiceLauncherHolder` that starts `MediaDownloadService`.
- `onCreate` order: API sessions first, then the Coil image loader (before widgets and services), Media3 Cast from `BoxLoreCastOptionsProvider`, the shared `UserPreferencesRepository`, the container and its holders. PostHog starts later on a background thread through `PostHogStartup`, after `AnalyticsHelper.initPolicy`.
- `StartupWorkGate` holds optional work until the first screen commits: cloud sync, Smart Downloads reconciliation, feed address repair scheduling, foreground subscription sync and legacy RSS repair. Initializers run one at a time.
- `StartupWorkGate` holds optional work until the first screen commits: cloud sync, Smart Downloads reconciliation, feed address repair scheduling, foreground subscription sync, legacy RSS repair and the first publish of the widget picker previews. Initializers run one at a time.
- Home releases the gate after loaded content and two frames, other start routes after two frames, and a headless worker or widget launch at the first idle main queue. An Activity stopping early also releases it; configuration recreation does not.
- Missing DataStore appearance keys are filled from the theme fast cache off the main thread; existing choices are never overwritten.
- `TabletAlphaStartupGate` shows a one-time notice on tablet windows of tablet-sized displays before the app root composes; update and survey prompts wait behind it.
Expand Down Expand Up @@ -94,7 +94,7 @@ src/main/java/cx/aswin/boxlore/
AdaptivePlayerChrome, announcement/, libraryimport/, logic/
updates/ AppUpdates, UpdateChecker, direct and Play sources, AppUpdatesPanel
util/ InstallSource
widgets/ HomeScreenWidgetsInstaller and widget adapters
widgets/ HomeScreenWidgetsInstaller, widget adapters and HomeScreenWidgetPreview (Settings' widget pictures)
src/direct/ APK downloader and installer (debug, release, updateTest)
src/playRelease/ Play stub installer
src/debug/ App Check debug provider, ADB-only notification preview receiver
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ class BoxLoreApplication :
subscriptionRepository = { container.subscriptionRepository },
userPreferencesRepository = userPreferencesRepository,
adaptiveScorer = { container.adaptiveCandidateScorer },
awaitStartupReady = { startupWorkGate.awaitReady() },
)
engagementPromptCoordinator =
EngagementPromptCoordinator(userPreferencesRepository, cx.aswin.boxlore.core.prefs.NpsSurveyStore(this))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
Expand All @@ -24,11 +26,15 @@ import cx.aswin.boxlore.feature.settings.SettingsScreen
import cx.aswin.boxlore.feature.settings.SettingsScreenConfig
import cx.aswin.boxlore.feature.settings.downloads.AutoDownloadSettingsScreen
import cx.aswin.boxlore.feature.settings.downloads.SmartDownloadsSettingsScreen
import cx.aswin.boxlore.feature.settings.pages.AddableWidget
import cx.aswin.boxlore.feature.settings.pages.AppearanceActions
import cx.aswin.boxlore.feature.settings.pages.AppearanceUiState
import cx.aswin.boxlore.feature.settings.pages.AppearanceWidgetPreview
import cx.aswin.boxlore.feature.settings.pages.PlaybackActions
import cx.aswin.boxlore.feature.settings.pages.PlaybackUiState
import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets
import cx.aswin.boxlore.ui.libraryimport.OpmlImportState
import cx.aswin.boxlore.widgets.HomeScreenWidgetPreview
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
Expand Down Expand Up @@ -167,6 +173,35 @@ private fun resetSettingsAnalytics() {
}
}

/** Every boxlore widget under its picker name, for Appearance › Widgets. Reads strings only. */
internal fun addableWidgets(context: android.content.Context): List<AddableWidget> = HomeScreenWidgets.all.map { widget ->
AddableWidget(
key = widget.key,
name = context.getString(widget.label),
description = context.getString(widget.description),
previewWidthDp = widget.previewWidthDp,
previewHeightDp = widget.previewHeightDp,
usesShowsCoverStyle = widget.usesShowsCoverStyle,
)
}

/** Appearance › Widgets draws the real widgets, again whenever a setting changes how they look. */
private fun widgetPreview(w: NavGraphWiring): AppearanceWidgetPreview = { widget, modifier ->
val settings = w.settingsState
val look =
listOf(
settings.widgetAppearance,
settings.widgetShowsArtwork,
settings.themeConfig,
settings.themeBrand,
settings.surfaceStyle,
settings.useDynamicColor,
settings.fontRoundness,
settings.savedCustomTheme,
)
HomeScreenWidgetPreview(widget.key, DpSize(widget.previewWidthDp.dp, widget.previewHeightDp.dp), look, modifier)
}

private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings {
val scope = w.scope
val userPrefs = w.userPrefs
Expand All @@ -183,6 +218,8 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings {
currentOpenAppTo = settingsState.openAppTo,
homeShortcutsInLibrary = settingsState.homeShortcutsInLibrary,
currentWidgetAppearance = settingsState.widgetAppearance,
currentWidgetShowsArtwork = settingsState.widgetShowsArtwork,
addableWidgets = addableWidgets(w.context),
currentExploreDefaultTab = settingsState.exploreDefaultTab,
currentSubscriptionsDefaultTab = settingsState.subscriptionsDefaultTab,
currentSubscriptionsTabStyle = settingsState.subscriptionsTabStyle,
Expand Down Expand Up @@ -225,6 +262,12 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings {
onSetWidgetAppearance = { appearance ->
scope.launch { userPrefs.setWidgetAppearance(appearance) }
},
onSetWidgetShowsArtwork = { style ->
scope.launch { userPrefs.setWidgetShowsArtwork(style) }
},
canAddWidgets = { HomeScreenWidgets.canAdd(w.context) },
onAddWidget = { key -> HomeScreenWidgets.requestAdd(w.context, key) },
addedWidgets = HomeScreenWidgets.added,
onSetExploreDefaultTab = { tab ->
scope.launch { userPrefs.setExploreDefaultTab(tab) }
},
Expand All @@ -246,6 +289,7 @@ private fun buildAppearanceSettings(w: NavGraphWiring): AppearanceSettings {
val sessions by w.playbackRepository.resumeSessions.collectAsState(initial = emptyList())
HeroStylePreview(style, coverShade, collection, sessions, modifier, tideProgress = settingsState.heroTideProgress)
},
widgetPreview = widgetPreview(w),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ data class NavSettingsState(
val sameShowQueueOnly: Boolean,
val homeShortcutsInLibrary: Boolean,
val widgetAppearance: String,
val widgetShowsArtwork: String,
val exploreDefaultTab: String,
val subscriptionsDefaultTab: String,
val subscriptionsTabStyle: String = cx.aswin.boxlore.core.prefs.SubscriptionsTabStyle.TOP,
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@ fun BoxLoreAppRoot(
val widgetAppearance by userPrefs.widgetAppearanceStream.collectAsState(
initial = remember { userPrefs.cachedWidgetAppearance },
)
val widgetShowsArtwork by userPrefs.widgetShowsArtworkStream.collectAsState(
initial = remember { userPrefs.cachedWidgetShowsArtwork },
)
val exploreDefaultTab by userPrefs.exploreDefaultTabStream.collectAsState(
initial = remember { userPrefs.cachedExploreDefaultTab },
)
Expand Down Expand Up @@ -710,6 +713,7 @@ fun BoxLoreAppRoot(
homeShortcutsInLibrary = homeShortcutsInLibrary,
discoverySections = discoverySections,
widgetAppearance = widgetAppearance,
widgetShowsArtwork = widgetShowsArtwork,
exploreDefaultTab = exploreDefaultTab,
subscriptionsDefaultTab = subscriptionsDefaultTab,
subscriptionsTabStyle = subscriptionsTabStyle,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cx.aswin.boxlore.widgets

import android.annotation.SuppressLint
import android.appwidget.AppWidgetHostView
import android.content.Context
import android.view.MotionEvent
import android.view.View
import android.widget.RemoteViews
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.viewinterop.AndroidView
import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* The home screen widget with this [key], drawn by its own renderer with sample content. The widget
* is laid out at its own [size] and that picture is scaled to fit the modifier's bounds, so it looks
* the same at every window width. It is drawn again when [look] changes: pass everything that changes
* how a widget looks (its colours, the lettering, the Your Shows cover style). It is a picture:
* nothing in it can be tapped, and screen readers skip it.
*/
@Composable
internal fun HomeScreenWidgetPreview(key: String, size: DpSize, look: Any, modifier: Modifier = Modifier) {
val context = LocalContext.current
// Light and dark, and the font scale, change what the widget's own layout resolves to.
val configuration = LocalConfiguration.current
val views by produceState<RemoteViews?>(initialValue = null, key, look, configuration) {
value = withContext(Dispatchers.Default) { HomeScreenWidgets.preview(context, key) }
}
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val scale = widgetPictureScale(size, maxWidth, maxHeight)
AndroidView(
factory = ::WidgetPictureHost,
update = { host -> host.show(views) },
modifier =
Modifier.requiredSize(size).graphicsLayer {
scaleX = scale
scaleY = scale
},
)
}
}

/** How much a widget laid out at [size] is scaled to fit the space it has. It is shrunk to fit, never enlarged. */
internal fun widgetPictureScale(size: DpSize, maxWidth: Dp, maxHeight: Dp): Float = minOf(1f, maxWidth / size.width, maxHeight / size.height)

/**
* Holds one widget's views the way a home screen does: a list or grid a widget carries with it is
* only filled inside a widget host. Touches never reach the widget, so its buttons cannot control
* playback from Settings.
*/
@SuppressLint("ViewConstructor")
private class WidgetPictureHost(context: Context) : AppWidgetHostView(context) {
private var shown: RemoteViews? = null

init {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
}

fun show(views: RemoteViews?) {
if (views === shown) return
shown = views
removeAllViews()
if (views == null) return
// A preview that cannot be drawn leaves its frame empty; the widget's name still stands beside it.
runCatching { addView(views.apply(context, this), LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) }
}

override fun onInterceptTouchEvent(event: MotionEvent): Boolean = true

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import cx.aswin.boxlore.feature.widgets.LibraryWidgetCoordinator
import cx.aswin.boxlore.feature.widgets.LibraryWidgetDependencies
import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetCoordinator
import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependencies
import cx.aswin.boxlore.feature.widgets.WidgetPickerPreviews
import cx.aswin.boxlore.feature.widgets.configureLibraryWidgets
import cx.aswin.boxlore.feature.widgets.configureNowPlayingWidget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch

Expand All @@ -27,6 +30,7 @@ object HomeScreenWidgetsInstaller {
subscriptionRepository: () -> SubscriptionRepository,
userPreferencesRepository: UserPreferencesRepository,
adaptiveScorer: () -> AdaptiveCandidateScorer,
awaitStartupReady: suspend () -> Unit = {},
) {
configureNowPlayingWidget(
object : NowPlayingWidgetDependencies {
Expand Down Expand Up @@ -55,8 +59,9 @@ object HomeScreenWidgetsInstaller {
}
},
)
// Re-render when widget chrome source, Appearance theme, lettering, or the Your Shows artwork style changes.
scope.launch {
// Everything that changes how a widget looks: widget chrome source, Appearance theme, lettering
// and the Your Shows artwork style.
val widgetLook =
combine(
combine(
userPreferencesRepository.widgetAppearanceStream,
Expand All @@ -67,10 +72,25 @@ object HomeScreenWidgetsInstaller {
) { _, _, _, _, _ -> Unit },
userPreferencesRepository.fontRoundnessStream,
userPreferencesRepository.widgetShowsArtworkStream,
) { _, _, _ -> Unit }.collect {
) { _, _, _ -> Unit }
scope.launch {
widgetLook.collect {
NowPlayingWidgetCoordinator.requestRefresh(context)
LibraryWidgetCoordinator.requestRefresh(context)
}
}
// The widget picker shows the same look the placed widgets have.
scope.launch { publishWhenStartupReady(widgetLook, awaitStartupReady) { WidgetPickerPreviews.refresh(context) } }
}

/**
* Runs [publish] for the current look and again for every later one. Picker previews are optional
* work, so the first run waits for [awaitStartupReady] and never competes with the first screen;
* once the app is up, a changed look is published at once. A newer look replaces one still being
* published.
*/
internal suspend fun publishWhenStartupReady(looks: Flow<Unit>, awaitStartupReady: suspend () -> Unit, publish: suspend () -> Unit) {
awaitStartupReady()
looks.collectLatest { publish() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cx.aswin.boxlore.navigation

import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import cx.aswin.boxlore.feature.widgets.HomeScreenWidgets
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AddableWidgetsTest {
private val context: Context = ApplicationProvider.getApplicationContext()

@Test
fun settingsOffersEveryWidgetUnderItsPickerName() {
val offered = addableWidgets(context)

assertEquals(HomeScreenWidgets.all.map { it.key }, offered.map { it.key })
assertEquals("Now Playing · Full Controls", offered.first().name)
assertEquals("Library · New Episodes", offered.last().name)
assertTrue(offered.all { it.name.isNotBlank() && it.description.isNotBlank() })
assertEquals("every name is told apart", offered.size, offered.map { it.name }.toSet().size)
// The two round players are small enough to sit side by side in Settings.
assertEquals(listOf("round", "round_next"), offered.filter { it.previewWidthDp <= 200 }.map { it.key })
}
}
Loading
Loading