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
45 changes: 30 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ app filtering by package name/signature/regex, and dark mode.
```

Unit tests live in `app/src/test/` (JUnit 4, Robolectric, MockK, kotlinx-coroutines-test; repository fakes in
`fakes/`). There is no `src/androidTest/` source set and no instrumentation-test dependencies.
`fakes/`). Compose UI (`createComposeRule`), Glance (`runGlanceAppWidgetUnitTest`) and Room migration
(`MigrationTestHelper`) tests also run there under Robolectric; the schema exports are added to debug assets for them.
There is no `src/androidTest/` source set and no instrumentation-test dependencies.

## Architecture

Expand All @@ -50,19 +52,25 @@ implementing `NavKey`, defined in `Routes.kt`. `DevDrawerHost.kt` maps each rout
`remember { Navigator(navigationState) }` in `MainActivity`, passed down as a parameter) wraps the back stack mutations.
The root composable `DevDrawerApp` (`DevDrawerApp.kt`) owns the `Scaffold`, top bar, and bottom nav bar with three
top-level routes: `WidgetListRoute`, `WidgetProfilesRoute`, and `SettingsRoute`. `AboutRoute` and the detail routes
`WidgetEditorRoute(id)` / `WidgetProfileEditorRoute(id)` are also defined in `Routes.kt`.
`WidgetEditorRoute(id)` / `WidgetSetupRoute(widgetId)` / `WidgetProfileEditorRoute(id)` are also defined in `Routes.kt`.
`widgetLaunchFor()` (`WidgetLaunch.kt`) decides which route a widget intent opens; `MainActivity` sets the
configure result (`RESULT_OK` when setup finishes) when the launcher placed or reconfigures a widget.

### Data Layer

Room database (`DevDrawerDatabase`, version 3) with three entities and DAOs:
Room database (`DevDrawerDatabase`, version 4) with three entities and DAOs:

| Entity | DAO | Purpose |
|-----------------|--------------------|-----------------------------------------------------------|
| `Widget` | `WidgetDao` | Home screen widget instances |
| `Widget` | `WidgetDao` | Home screen widgets: name, profile, header colour, sort |
| `WidgetProfile` | `WidgetProfileDao` | Named filter profiles |
| `PackageFilter` | `PackageFilterDao` | Per-profile filter rules (package name, regex, signature) |

DB schema migrations live in `Migrations.kt`. Room schema JSON exports go to `/schemas/`.
DB schema migrations live in `Migrations.kt`. Room schema JSON exports go to `/schemas/`. `Widget.color` is the legacy
ARGB header colour, kept so the table needs no rebuild; `headerColor` (`WidgetHeaderColor`) replaces it.

Which apps a profile shows is decided in one place: `matching()` and `SortOrder.comparator()` in `apps/AppMatching.kt`,
used by the widget, the Widgets list, both editors and the setup screen. `IAppsService` reads installed packages.

### Dependency Injection

Expand All @@ -71,15 +79,20 @@ Hilt throughout. Key modules:
- `ApplicationModule` — provides `SharedPreferences`
- `DatabaseModule` — provides the Room DB and DAOs
- `RepositoryModule` — binds the repository interfaces (`IWidgetRepository`, `IWidgetProfileRepository`,
`IPackageFilterRepository`) to their implementations
`IPackageFilterRepository`), `IAppsService` and `ISortOrderSettings` to their implementations
- `WidgetModule` — provides `WidgetContentLoader`; `WidgetEntryPoint` gives the Glance widget access to it

### Widget System

- `DDWidgetProvider` — `AppWidgetProvider` that renders `RemoteViews`; **never rename this class** as it breaks existing
placed widgets
- `WidgetService` + `WidgetAppsListViewFactory` — `RemoteViewsService` that populates the scrollable app list inside the
widget
- `ClickHandlingActivity` — trampoline activity for widget item taps
The home-screen widget is built with **Jetpack Glance**.

- `DDWidgetProvider` — `GlanceAppWidgetReceiver` for `DevDrawerGlanceWidget`; **never rename this class** as it breaks
existing placed widgets
- `appwidget/glance/` — `DevDrawerGlanceWidget` loads the widget's content, `DevDrawerWidgetContent` draws it (header
palette from `ui/theme/WidgetHeaderPalette.kt`, empty state with "Choose apps"), `RefreshAction` reloads it
- `WidgetContentLoader` — a widget's matching apps in its sort order (its own, else the Settings default); app icons are
scaled to the 40 dp they are drawn at, because every icon travels in the widget's RemoteViews
- `ClickHandlingActivity` — trampoline activity for widget item taps (launch, uninstall, app details)
- `UpdateWidgetsWorker` — `WorkManager` worker to refresh all widgets
- `AppInstallationReceiver` — `BroadcastReceiver` for `PACKAGE_ADDED`/`PACKAGE_REMOVED` events that triggers widget
refresh
Expand All @@ -88,7 +101,8 @@ Hilt throughout. Key modules:

```
de.psdev.devdrawer/
├── appwidget/ # Widget provider, service, click handler
├── apps/ # Installed apps and profile matching
├── appwidget/ # Glance widget, receiver, content loader, click handler
├── database/ # Room entities, DAOs, migrations
├── profiles/ # WidgetProfile feature (UI + repository)
│ └── ui/
Expand All @@ -101,16 +115,17 @@ de.psdev.devdrawer/
└── widgets/ # Widget config feature (UI + repository)
└── ui/
├── editor/ # Widget editor screen & ViewModel
└── list/ # Widget list screen
├── list/ # Widget list screen
└── setup/ # Setup when a widget is placed: which apps it shows
```


## Build & Release

- Dependency versions are managed in the version catalog `gradle/libs.versions.toml` (no dependency-updates plugin;
Renovate is configured via `renovate.json`)
- `com.google.android.material:material` is required by the XML themes (`themes.xml`) and widget layouts, even though
all screens are Compose
- `com.google.android.material:material` is required by the XML themes (`themes.xml`), even though all screens are
Compose

- Signing config is read from `release.properties` (local) or CI env vars (`keystore_password`, `keystore_alias`,
`keystore_alias_password`) when `CI=true`
Expand Down
10 changes: 10 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ android {
includeAndroidResources = true
}
}
sourceSets {
// Room schema exports for MigrationTestHelper. Robolectric reads the app's merged assets, so they go into
// debug builds (a few KB of JSON); release builds don't include them.
debug.assets.srcDirs += files("$rootDir/schemas")
}
final def keystorePropertiesFile = rootProject.file("release.properties")
signingConfigs {
debug {
Expand Down Expand Up @@ -123,6 +128,7 @@ dependencies {
testImplementation libs.robolectric
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.room.testing

//
// Runtime dependencies
Expand Down Expand Up @@ -173,6 +179,10 @@ dependencies {
debugImplementation libs.androidx.ui.test.manifest
// Optional - Add full set of material icons
implementation libs.androidx.material.icons.extended
// Home-screen widget
implementation libs.androidx.glance.appwidget
implementation libs.androidx.glance.material3
testImplementation libs.androidx.glance.appwidget.testing
// Optional - Integration with activities
implementation libs.androidx.activity.compose
// Optional - Integration with ViewModels
Expand Down
4 changes: 0 additions & 4 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@
android:name=".receivers.PinWidgetSuccessReceiver"
android:exported="false" />

<service
android:name=".appwidget.WidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />

<!-- Disable auto-init of WorkManager -->
<provider
android:name="androidx.startup.InitializationProvider"
Expand Down
118 changes: 30 additions & 88 deletions app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
Expand All @@ -12,7 +13,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Grid3x3
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand All @@ -25,29 +25,24 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.psdev.devdrawer.analytics.AnalyticsOptInCard
import de.psdev.devdrawer.analytics.TrackingService
import de.psdev.devdrawer.analytics.showsAnalyticsOptIn
import de.psdev.devdrawer.database.Widget
import de.psdev.devdrawer.settings.SettingsViewModel
import de.psdev.devdrawer.settings.ThemeSetting
import de.psdev.devdrawer.ui.theme.DevDrawerTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mu.KotlinLogging

Expand All @@ -59,7 +54,9 @@ fun DevDrawerApp(
viewModel: SettingsViewModel = hiltViewModel(),
navigationState: NavigationState,
navigator: Navigator,
trackingService: TrackingService
trackingService: TrackingService,
onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() },
onWidgetSetupBack: (Int) -> Unit = { navigator.goBack() }
) {
val settings by viewModel.persistedSettings.collectAsState()
val darkTheme = when (settings.themeSetting) {
Expand All @@ -84,15 +81,6 @@ fun DevDrawerApp(
val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last() ?: navigationState.topLevelRoute

val (menuComposable, setMenu) = remember {
mutableStateOf<AppBarActions?>(null)
}

// Reset state on navigation change
LaunchedEffect(currentRoute) {
setMenu(null)
}

val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val navigationIcon: @Composable () -> Unit =
if (currentRoute !in topLevelRoutes) {
Expand All @@ -110,22 +98,16 @@ fun DevDrawerApp(
}

val needsOptIn by trackingService.needsOptIn.collectAsState()
if (needsOptIn) {
AnalyticsOptInDialog(
onOptIn = {
trackingService.optIn()
scope.launch {
snackbarHostState.showSnackbar(
message = optInThanksMessage,
actionLabel = okLabel,
duration = SnackbarDuration.Long
)
}
},
onOptOut = {
trackingService.optOut()
}
)
val onOptIn = {
trackingService.optIn()
scope.launch {
snackbarHostState.showSnackbar(
message = optInThanksMessage,
actionLabel = okLabel,
duration = SnackbarDuration.Long
)
}
Unit
}

Scaffold(
Expand All @@ -140,19 +122,22 @@ fun DevDrawerApp(
navigationIcon = navigationIcon,
title = {
Text(text = stringResource(id = currentRoute.title))
},
actions = {
menuComposable?.invoke(this)
}
)
},
content = { innerPadding ->
DevDrawerHost(
navigationState = navigationState,
navigator = navigator,
menuCallback = setMenu,
modifier = Modifier.padding(innerPadding)
)
Column(modifier = Modifier.padding(innerPadding)) {
if (showsAnalyticsOptIn(needsOptIn, currentRoute)) {
AnalyticsOptInCard(onOptIn = onOptIn, onOptOut = trackingService::optOut)
}
DevDrawerHost(
navigationState = navigationState,
navigator = navigator,
modifier = Modifier.weight(1f),
onWidgetSetupDone = onWidgetSetupDone,
onWidgetSetupBack = onWidgetSetupBack
)
}
},
bottomBar = {
NavigationBar {
Expand All @@ -178,49 +163,6 @@ fun DevDrawerApp(
}
}

@Composable
fun AnalyticsOptInDialog(
onOptIn: () -> Unit,
onOptOut: () -> Unit
) {
// Persist the dialog-show timestamp across config changes so rotation doesn't restart the delay.
val shownAt by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) }
var buttonsEnabled by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!buttonsEnabled) {
val remaining = 2500L - (System.currentTimeMillis() - shownAt)
if (remaining > 0) delay(remaining)
buttonsEnabled = true
}
}

AlertDialog(
onDismissRequest = { /* Not cancelable */ },
title = {
Text(text = stringResource(id = R.string.analytics_opt_in_title), fontWeight = FontWeight.Bold)
},
text = {
Text(text = stringResource(id = R.string.analytics_opt_in_message))
},
confirmButton = {
TextButton(
enabled = buttonsEnabled,
onClick = onOptIn
) {
Text(stringResource(id = R.string.analytics_opt_in))
}
},
dismissButton = {
TextButton(
enabled = buttonsEnabled,
onClick = onOptOut
) {
Text(stringResource(id = R.string.analytics_opt_out))
}
}
)
}

val topLevelRoutes = listOf(
WidgetListRoute,
WidgetProfilesRoute,
Expand Down
25 changes: 18 additions & 7 deletions app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
package de.psdev.devdrawer

import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import de.psdev.devdrawer.about.AboutScreen
import de.psdev.devdrawer.database.Widget
import de.psdev.devdrawer.profiles.ui.editor.WidgetProfileEditor
import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesScreen
import de.psdev.devdrawer.settings.SettingsScreen
import de.psdev.devdrawer.widgets.ui.editor.WidgetEditorScreen
import de.psdev.devdrawer.widgets.ui.list.WidgetListScreen
import de.psdev.devdrawer.widgets.ui.setup.WidgetSetupScreen

@Composable
fun DevDrawerHost(
navigationState: NavigationState,
navigator: Navigator,
menuCallback: AppBarActionsProvider,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() },
onWidgetSetupBack: (Int) -> Unit = { navigator.goBack() }
) {
val entryProvider = entryProvider {
entry<WidgetListRoute> {
WidgetListScreen(
onWidgetClick = { widget ->
navigator.navigate(WidgetEditorRoute(widget.id))
},
onChooseAppsClick = { widget ->
navigator.navigate(WidgetSetupRoute(widget.id))
}
)
}
Expand All @@ -46,18 +53,22 @@ fun DevDrawerHost(
entry<WidgetEditorRoute> { key ->
WidgetEditorScreen(
id = key.id,
menuCallback = menuCallback,
onBack = { navigator.goBack() },
onEditWidgetProfile = { profile ->
navigator.navigate(WidgetProfileEditorRoute(profile.id))
}
)
}
entry<WidgetSetupRoute> { key ->
// Registered after the app bar's back handling, so it wins: setup from outside the app closes the app.
BackHandler { onWidgetSetupBack(key.widgetId) }
WidgetSetupScreen(
widgetId = key.widgetId,
onDone = onWidgetSetupDone
)
}
entry<WidgetProfileEditorRoute> { key ->
WidgetProfileEditor(
profileId = key.id,
menuCallback = menuCallback,
onBack = { navigator.goBack() }
profileId = key.id
)
}
}
Expand Down
Loading
Loading