Skip to content
Open
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
72 changes: 72 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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 +149,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 same path filter as the build, 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.android == '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