Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c90f9ed
refactor(weather): share the widget snapshot transport across platforms
Vinson1014 Oct 16, 2026
1b97418
build(weather): generate township assets for Android widgets
Vinson1014 Oct 16, 2026
6851178
feat(weather): add Android home screen weather widgets
Vinson1014 Oct 16, 2026
fc71c51
feat(weather): sync Home weather with Android widgets
Vinson1014 Oct 16, 2026
8374451
feat(weather): open Home when an Android weather widget is tapped
Vinson1014 Oct 16, 2026
a33c930
fix: localize Android app names in additional widget locales
Vinson1014 Oct 16, 2026
64624f8
ci: run Android widget tests and lint
Vinson1014 Oct 16, 2026
6264096
fix(weather): publish Android widget clock anchors atomically
Vinson1014 Oct 16, 2026
8d3c0d3
fix(weather): reject invalid Android widget configuration targets
Vinson1014 Oct 16, 2026
88c1591
fix(weather): restore Android widget updates after storage failures
Vinson1014 Oct 16, 2026
250a457
test(weather): cover Android widget framework wiring
Vinson1014 Oct 16, 2026
ea6a2dd
test(weather): strengthen native widget timing regressions
Vinson1014 Oct 16, 2026
3fc75bc
docs(weather): document Android widget lifecycle contracts
Vinson1014 Oct 16, 2026
aa925a4
ci(android): configure the project before the Gradle scripts run
Vinson1014 Oct 16, 2026
39a807d
fix(weather): refine Android widget layout and refresh controls
YinCheng0106 Oct 18, 2026
7a0c2e0
feat(widget): adapt Small weather widgets to tall layouts
YinCheng0106 Oct 19, 2026
8fbcacb
feat(widget): improve Compact weather accessibility
YinCheng0106 Oct 20, 2026
02e4744
fix(widget): prevent Large Tight forecast clipping
YinCheng0106 Oct 20, 2026
a611ddf
fix(widget): prevent Large Roomy forecast clipping
YinCheng0106 Oct 20, 2026
20bd614
fix(widget): prevent Thai badge glyph clipping
YinCheng0106 Oct 20, 2026
8b2af0c
fix(widget): retire a forecast demand the cache already answers
PiscesXD Oct 23, 2026
3068dd6
chore(widget): answer every widget storage call, even when it fails
PiscesXD Oct 23, 2026
82f04dd
fix(widget): route a cold-start widget tap to Home
PiscesXD Oct 23, 2026
6eeb47f
fix(widget): name each rotation's box instead of guessing by width
PiscesXD Oct 23, 2026
4065bcb
fix(widget): tell an unreadable catalog apart from a deleted town
PiscesXD Oct 23, 2026
9d30c49
chore(android): use the resources root tag for localized app names
PiscesXD Oct 23, 2026
86571a2
ci(android): run the emulator job only for native changes
PiscesXD Oct 23, 2026
824fa11
ci: accept CRLF in the Android township directory check
PiscesXD Oct 23, 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
Loading