Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8b739eb
refactor(weather): share the widget snapshot transport across platforms
Vinson1014 Oct 16, 2026
cd17cef
build(weather): generate township assets for Android widgets
Vinson1014 Oct 16, 2026
a672a6a
feat(weather): add Android home screen weather widgets
Vinson1014 Oct 16, 2026
2b76656
feat(weather): sync Home weather with Android widgets
Vinson1014 Oct 16, 2026
ea6d84c
feat(weather): open Home when an Android weather widget is tapped
Vinson1014 Oct 16, 2026
0991dd7
fix: localize Android app names in additional widget locales
Vinson1014 Oct 16, 2026
fd7b60b
ci: run Android widget tests and lint
Vinson1014 Oct 16, 2026
7fe6c4f
fix(weather): publish Android widget clock anchors atomically
Vinson1014 Oct 16, 2026
80ab323
fix(weather): reject invalid Android widget configuration targets
Vinson1014 Oct 16, 2026
5b23fc5
fix(weather): restore Android widget updates after storage failures
Vinson1014 Oct 16, 2026
93dacc1
test(weather): cover Android widget framework wiring
Vinson1014 Oct 16, 2026
0a80b7d
test(weather): strengthen native widget timing regressions
Vinson1014 Oct 16, 2026
995f5cf
docs(weather): document Android widget lifecycle contracts
Vinson1014 Oct 16, 2026
a76cf4e
ci(android): configure the project before the Gradle scripts run
Vinson1014 Oct 16, 2026
833ccdb
fix(weather): refine Android widget layout and refresh controls
YinCheng0106 Oct 18, 2026
c40d5f7
feat(widget): adapt Small weather widgets to tall layouts
YinCheng0106 Oct 19, 2026
3343676
feat(widget): improve Compact weather accessibility
YinCheng0106 Oct 20, 2026
3b429a3
fix(widget): prevent Large Tight forecast clipping
YinCheng0106 Oct 20, 2026
63ec0ad
fix(widget): prevent Large Roomy forecast clipping
YinCheng0106 Oct 20, 2026
08cee73
fix(widget): prevent Thai badge glyph clipping
YinCheng0106 Oct 20, 2026
0557461
fix(widget): retire a forecast demand the cache already answers
PiscesXD Oct 23, 2026
67c0ba1
chore(widget): answer every widget storage call, even when it fails
PiscesXD Oct 23, 2026
b15f596
fix(widget): route a cold-start widget tap to Home
PiscesXD Oct 23, 2026
ce8eb33
fix(widget): name each rotation's box instead of guessing by width
PiscesXD Oct 23, 2026
8e5276c
fix(widget): tell an unreadable catalog apart from a deleted town
PiscesXD Oct 23, 2026
2bcc698
chore(android): use the resources root tag for localized app names
PiscesXD Oct 23, 2026
c5f15e0
ci(android): run the emulator job only for native changes
PiscesXD Oct 23, 2026
3742438
ci: accept CRLF in the Android township directory check
PiscesXD Oct 23, 2026
911d720
fix(weather): fill tall widget cells instead of leaving empty space
PiscesXD Oct 30, 2026
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
83 changes: 83 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
contents: read
outputs:
android: ${{ steps.filter.outputs.android }}
native: ${{ steps.filter.outputs.native }}
release: ${{ steps.release.outputs.builds }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -38,6 +39,16 @@ jobs:
- 'assets/**'
- 'lib/**'
- 'pubspec.yaml'
# The emulator job's filter, and deliberately narrower than
# `android`. What it tests — provider metadata, RemoteViews
# inflation, the Widget's own SQLite — is decided entirely by the
# native sources, the resources and the manifest. A Dart-only change
# cannot alter any of the three, so booting an emulator for one
# spends ~15 minutes of a runner to re-prove an unchanged tree.
native:
- 'android/**'
- '.github/workflows/android.yml'
- 'tool/dev/test_android_device.sh'

# Whether release.yml builds this push, for the job below. It publishes
# a snapshot only when a commit since the last tag carries a changelog
Expand Down Expand Up @@ -112,6 +123,20 @@ jobs:
bash tool/dev/deps.sh
bash tool/dev/codegen.sh

# The Kotlin Widget tests, which `tool/dev/test.sh` cannot see: its suite
# is Dart, and none of it compiles a line of the native policy. Before
# this step existed, a change to the Widget's JSON decoding, ordering or
# presentation could ship with every other check green.
- name: Run native Widget unit tests
run: bash tool/dev/test_android.sh

# Lint reads the merged manifest and resources, which the unit tests
# cannot: a receiver without `exported`, an API-31 attribute on a minSdk-26
# app. It is a gate on new issues only — the pre-existing ones are in
# android/app/lint-baseline.xml.
- name: Run Android lint
run: bash tool/dev/lint_android.sh

# No keystore, and deliberately so. This job builds the artifact nobody
# installs — release.yml signs and uploads the one that ships — so the
# release keystore buys nothing here, and asking for it is what breaks
Expand All @@ -135,3 +160,61 @@ jobs:
path: build/app/outputs/flutter-apk/*.apk
retention-days: 7
compression-level: 6

# The Widget on a real Android runtime.
#
# `tool/dev/test_android.sh` is a JVM suite: it cannot inflate a `RemoteViews`
# through the platform's own inflater, cannot read `AppWidgetProviderInfo` the
# way a launcher does, and cannot open the device's SQLite. Those three are what
# `src/androidTest` covers, and a device is the only place they can run — so
# this job exists, and a green `Android` job above says nothing about them.
#
# Gated on the `native` filter rather than the build's `android`, and
# deliberately not on the release decision: a device run is worth doing whether
# or not release.yml also builds this push.
Instrumented:
needs: changes
if: ${{ needs.changes.outputs.native == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Java
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "temurin"
java-version: "25"
cache: "gradle"

- name: Install toolchain (mise)
uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1

- name: Install dependencies
run: bash tool/dev/deps.sh

# Hardware acceleration: without KVM the emulator boots in software and the
# run takes the better part of an hour.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run the instrumented Widget tests
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
# One recent platform. The JVM suite already runs the layouts and the
# store against two SDK levels (26 and 35) through Robolectric; this
# job is for the real framework, not for a version matrix it cannot
# afford.
api-level: 35
arch: x86_64
target: google_apis
disable-animations: true
script: bash tool/dev/test_android_device.sh
15 changes: 14 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ plugins where practical (e.g. `core/platform/` device_info, compass).

## Persistence

everything persisted lives in **SQLite**, split
everything persisted lives in **SQLite**. Flutter's stores are split
into two files by *durability*: `dpip.db` in application-support (settings,
`tle`, `mesh_*`) and `http_etag_cache.db` in the platform cache directory
(`http_cache`, `net_bucket`). The split is the point — the OS may empty the
Expand All @@ -352,6 +352,19 @@ may live there, and `AppDatabase.clearCache()` holds no handle to the durable
file, which makes "clear cache deleted my settings" unavailable rather than
merely avoided. Tables are one-per-category and each is owned by exactly one
store (`core/storage/app_database.dart` documents the map).

Android's home-screen Widget keeps its own native-owned `dpip_widget.db` in the
app's database directory, opened only by `WidgetStore`. The boundary runs both
ways — Dart reaches it only through `WidgetSnapshotChannel` and never opens the
file; native code never opens Flutter's databases. Widget instance choices and
the published location catalog are durable and exist nowhere else: Dart cannot
rebuild them, so schema rebuilds and snapshot clears must never drop them;
snapshots, forecasts, ordering and demand state are rebuildable.
`AppDatabase.clearCache()` does not reach this file; any native clear goes
through the channel under the same split. The table map, ordering fences and
reconcile behaviour live with the code that enforces them
(`android/app/src/main/kotlin/com/exptech/dpip/widget/WidgetStore.kt`).

Settings go through the typed `SettingsStore` facade
(`core/settings/settings_store.dart`), keyed by a `SettingKey<T>` from the
`SettingKeys` registry (`core/settings/setting_keys.dart`) — **never a raw
Expand Down
67 changes: 67 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion

// Robolectric inflates the Widget's real layouts and resolves its real
// resources, so the unit-test run needs the merged resource table — which
// is off by default for Android unit tests.
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}

compileOptions {
// Required by awesome_notifications (uses desugared java.time/etc.).
isCoreLibraryDesugaringEnabled = true
Expand All @@ -62,6 +71,12 @@ android {
versionCode = dpipVersionCode
versionName = dpipVersionName

// The Widget's on-device suite (`src/androidTest`), which a JVM runner
// cannot stand in for: the real `RemoteViews` inflater, the real SQLite,
// and the real provider metadata the launcher reads. It runs from
// android.yml on an emulator, which is the only place a device exists.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

// Flutter ≥3.35 auto-sets release abiFilters to its 3 supported
// architectures (union with whatever defaultConfig declares).
// Keep only ARM64 devices and x86_64 emulators.
Expand All @@ -84,6 +99,18 @@ android {
}
}

// Android lint, as a gate rather than a report.
//
// `lintDebug` runs from `tool/dev/lint_android.sh` and from android.yml. The
// baseline it points at records the issues that were already there before
// the Widget existed — a `NewApi` call in `LocationFetcher`, which belongs
// to the location reporter rather than to this change — so the gate is about
// anything *new*. Without it the task fails on day one on somebody else's
// code, which is how a lint gate gets deleted instead of fixed.
lint {
baseline = file("lint-baseline.xml")
}

buildTypes {
release {
signingConfig =
Expand All @@ -102,11 +129,30 @@ kotlin {
}
}


// Robolectric reads the app's *merged assets* — the generated township
// resources are app assets, and the unit-test packaging task therefore consumes
// the directory the Flutter Gradle plugin writes into. AGP declares that edge
// for the app tasks but not for the unit-test packaging tasks, so Gradle's
// task-order validation fails the build with "uses this output ... without
// declaring an explicit or implicit dependency". Declaring it is the fix Gradle
// itself proposes; the alternative is dropping `isIncludeAndroidResources`,
// which is what makes `RemoteViews.apply` and the SQLite/resources tests
// possible at all.
tasks.matching { it.name == "packageDebugUnitTestForUnitTest" }.configureEach {
dependsOn("copyFlutterAssetsDebug")
}
tasks.matching { it.name == "packageReleaseUnitTestForUnitTest" }.configureEach {
dependsOn("copyFlutterAssetsRelease")
}

flutter {
source = "../.."
}

dependencies {
// Only the native widget picker uses MDC; Flutter and RemoteViews keep their themes.
implementation("com.google.android.material:material:1.14.0")
// Lets MapSnapshotChannel / MapCacheChannel use MapLibre APIs directly:
// the maplibre_gl plugin's SDK isn't on the app's compile classpath.
// maplibre_gl ≥0.26 switched to the OpenGL artifact and excludes the
Expand Down Expand Up @@ -136,4 +182,25 @@ dependencies {
implementation("androidx.work:work-runtime:2.11.2")
// Backports java.time/etc. for awesome_notifications (see compileOptions).
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")

// The Widget's native unit tests.
//
// JUnit alone covers the pure policy (JSON, contracts, ordering,
// presentation, solar time, the time protocols). The wiring that only exists
// at runtime — `RemoteViews.apply` against the real layouts, the SQLite
// store's SQL, WorkManager's scheduling, the configuration Activity and the
// provider's restore path — needs an Android runtime, and there is no device
// or emulator here or on the CI runner. Robolectric supplies one on the JVM,
// so those tests run everywhere `tool/dev/test_android.sh` runs.
//
// `work-testing` provides the WorkManager test initializer, which is the
// only way to inspect what was enqueued without a real scheduler.
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.16")
testImplementation("androidx.test:core:1.6.1")
testImplementation("androidx.work:work-testing:2.11.2")

androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
}
Loading