diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 40738518c..7a9da209d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 71b96ba8c..e2f89121f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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` from the `SettingKeys` registry (`core/settings/setting_keys.dart`) — **never a raw diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 613bb3dfb..fc9feff48 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 @@ -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. @@ -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 = @@ -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 @@ -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") } diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml new file mode 100644 index 000000000..41951f5b9 --- /dev/null +++ b/android/app/lint-baseline.xml @@ -0,0 +1,590 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/androidTest/kotlin/com/exptech/dpip/widget/WidgetOnDeviceTest.kt b/android/app/src/androidTest/kotlin/com/exptech/dpip/widget/WidgetOnDeviceTest.kt new file mode 100644 index 000000000..f33ead516 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/exptech/dpip/widget/WidgetOnDeviceTest.kt @@ -0,0 +1,233 @@ +package com.exptech.dpip.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.view.View +import android.widget.FrameLayout +import android.widget.RemoteViews +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.exptech.dpip.R +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The Widget on a real device. + * + * The JVM suite covers the policy, and Robolectric covers the layouts and the + * store against a simulated framework. Three things it cannot stand in for, and + * which this suite therefore checks against the real ones: + * + * * the **provider metadata** the launcher reads, resolved from the real + * `AppWidgetProviderInfo`; + * * the **real `RemoteViews` inflater** and its click handling; + * * the **real SQLite** the store is built on, at the device's schema version. + * + * It runs from `android.yml` on an emulator. A device is the only place these + * assertions can be made, so `tool/dev/test_android.sh` does not run them and a + * green JVM run says nothing about them. + */ +@RunWith(AndroidJUnit4::class) +class WidgetOnDeviceTest { + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val component = ComponentName( + context, + WeatherWidgetProvider::class.java, + ) + + /** + * A database of this suite's own. + * + * It runs on a real device, and the device may be somebody's daily phone with + * a Weather Widget on the home screen. Deleting `dpip_widget.db` — the name + * production uses — would take that Widget and its chosen town with it, so + * nothing here ever names that file. The store takes the name it opens + * precisely so a test can stay away from it. + */ + private val testDatabase = "dpip_widget_instrumented_test.db" + + private fun openStore(): WidgetStore = + WidgetStore.openUncached(context, databaseName = testDatabase) + + @Before + fun setUp() { + context.deleteDatabase(testDatabase) + WidgetStore.resetForTest() + } + + @After + fun tearDown() { + context.deleteDatabase(testDatabase) + WidgetStore.resetForTest() + } + + @Test + fun theLauncherReadsTheMetadataThisWidgetNeeds() { + val info: AppWidgetProviderInfo = AppWidgetManager.getInstance(context) + .installedProviders + .firstOrNull { it.provider == component } + ?: error("the provider is not installed; check the manifest and the metadata") + + assertEquals("WorkManager owns the cadence", 0, info.updatePeriodMillis) + assertEquals( + "the configuration Activity must be resolved by the framework", + WeatherWidgetConfigurationActivity::class.java.name, + info.configure?.className, + ) + assertTrue("resizable horizontally", info.resizeMode and AppWidgetProviderInfo.RESIZE_HORIZONTAL != 0) + assertTrue("resizable vertically", info.resizeMode and AppWidgetProviderInfo.RESIZE_VERTICAL != 0) + assertNotNull(info.initialLayout) + if (android.os.Build.VERSION.SDK_INT >= 28) { + assertTrue("launcher reconfiguration replaces the in-widget settings button", + info.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_RECONFIGURABLE != 0) + } + } + + @Test + fun theConfigurationActivityIsReachableThroughItsAction() { + val resolved = context.packageManager.resolveActivity( + Intent(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE).setPackage(context.packageName), + 0, + ) + assertEquals( + WeatherWidgetConfigurationActivity::class.java.name, + resolved?.activityInfo?.name, + ) + } + + @Test + fun theProductionDatabaseIsNeverNamedByThisSuite() { + // A guard on the guard: if somebody later makes this suite open the + // production file, this fails before anything deletes it. + assertTrue( + "the suite must not name the production database", + testDatabase != WidgetStore.DATABASE_NAME, + ) + assertTrue( + "the production database file must not exist in this suite's path", + !context.getDatabasePath(WidgetStore.DATABASE_NAME).exists() || + openStore().readInstances().isEmpty(), + ) + } + + @Test + fun theRealRemoteViewsInflaterAcceptsEveryLayout() { + // `RemoteViews` refuses anything outside its allowlist — a `` + // divider is the classic case — and it refuses it here, at apply time, + // not at compile time. + for (layoutId in listOf( + R.layout.widget_small, + R.layout.widget_small_compact, + R.layout.widget_medium, + R.layout.widget_large, + R.layout.widget_large_tight, + R.layout.widget_forecast_item, + R.layout.widget_forecast_item_large, + R.layout.widget_empty, + R.layout.widget_preview, + )) { + val applied: View = RemoteViews(context.packageName, layoutId) + .apply(context, FrameLayout(context)) + assertNotNull(applied) + } + } + + @Test + fun theRealRemoteViewsAppliesRefreshFeedbackAndRestoresIdle() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val base = WidgetPresenter.present( + sizeClass = WidgetSizeClass.Medium, + strings = WidgetUpdater.strings(context), + address = WidgetAddress.Saved("407"), + locationName = "西屯區", + stored = null, + forecast = null, + locationDegradation = null, + isCatalogMember = true, + calibratedNowMilliseconds = null, + latitude = 24.181, + longitude = 120.645, + ) + val view = WidgetRenderer.render(context, base, 42).apply(context, FrameLayout(context)) + for (feedback in listOf(WidgetRefreshFeedback.Queued, WidgetRefreshFeedback.Running, + WidgetRefreshFeedback.Failed, WidgetRefreshFeedback.Updated, null)) { + WidgetRenderer.render(context, base.copy(refreshFeedback = feedback), 42).reapply(context, view) + val busy = feedback == WidgetRefreshFeedback.Queued || feedback == WidgetRefreshFeedback.Running + val progress = view.findViewById(R.id.widget_refresh_progress) + assertTrue(progress.isIndeterminate) + assertTrue("the launcher can animate the arrow by drawable level", + progress.indeterminateDrawable is android.graphics.drawable.RotateDrawable) + assertEquals(if (busy) View.VISIBLE else View.GONE, progress.visibility) + assertEquals(!busy, view.findViewById(R.id.widget_refresh).isEnabled) + assertEquals(context.getString(R.string.widget_no_data), + view.findViewById(R.id.widget_empty_message).text.toString()) + } + } + } + + @Test + fun theRealStoreRoundTripsASnapshot() { + val store = openStore() + val address = WidgetAddress.CurrentLocation + assertTrue( + store.writeCatalog( + listOf( + WidgetCatalogLocation.create("407", "西屯區", "臺中市", 24.181, 120.645)!!, + ), + 1L, + ), + ) + assertTrue(store.writeInstance(11, address, null, 1L)) + + val snapshot = CurrentWeatherSnapshot( + schemaVersion = WidgetSnapshotDecoder.currentWeatherSchemaVersion, + sourceIdentifier = address.sourceIdentifier, + regionCode = "407", + regionName = "西屯區", + observationTime = 1_791_454_316, + stationName = "西屯", + weather = "多雲有雨", + weatherCode = 206, + condition = WeatherCondition.Rain, + isNight = false, + nextDayNightTransitionTime = 1_791_457_916, + calibratedTimeOffsetMilliseconds = -1_200, + temperature = 27.5, + humidity = 78, + rain = 0.4, + windDirection = "北北西", + windSpeed = 1.3, + apparentTemperature = 30.2, + ) + + val tokens = store.beginRefreshWrites(address, includeForecast = true)!! + assertEquals( + WidgetWriteResult.Written, + store.commitCurrentWeather(tokens.currentWeather, snapshot, writtenAt = 1L), + ) + + val stored = store.readCurrentWeather(address)!! + assertEquals(1_791_454_316L, stored.snapshot.observationTime) + assertEquals(206, stored.snapshot.weatherCode) + assertEquals(27.5, stored.snapshot.temperature!!, 1e-9) + + // A clear fences the ticket and leaves the catalogue and the choice. + assertTrue(store.clearCurrentWeather()) + assertEquals( + WidgetWriteResult.Superseded, + store.commitCurrentWeather(tokens.currentWeather, snapshot, writtenAt = 2L), + ) + assertNotNull(store.readInstance(11)) + assertEquals(1, store.readCatalog()!!.locations.size) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 98892ee06..7bf338d6c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -173,6 +173,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + existing.locationGeneration + } + + if (candidate.valueTime != existing.valueTime) { + return candidate.valueTime > existing.valueTime + } + return candidate.snapshotGeneration > existing.snapshotGeneration + } + + /** + * The location generation a candidate commit leaves behind. + * + * Only a Current Location has one that outlives a single snapshot: arrival + * at a new township resets it, because "the newest acquisition in this + * township" is meaningless across a move. A same-township commit carries the + * maximum forward, so the fence only ever moves forward. + */ + fun locationGeneration( + address: WidgetAddress, + existing: SnapshotOrderingMetadata?, + candidate: SnapshotOrderingMetadata, + ): Long { + if (address !is WidgetAddress.CurrentLocation) return candidate.snapshotGeneration + if (existing == null) return candidate.snapshotGeneration + if (candidate.regionCode != existing.regionCode) return candidate.snapshotGeneration + return maxOf(existing.locationGeneration, candidate.snapshotGeneration) + } +} diff --git a/android/app/src/main/kotlin/com/exptech/dpip/widget/WeatherWidgetConfigurationActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/widget/WeatherWidgetConfigurationActivity.kt new file mode 100644 index 000000000..bf3c5f194 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/widget/WeatherWidgetConfigurationActivity.kt @@ -0,0 +1,306 @@ +package com.exptech.dpip.widget + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.ListView +import android.widget.TextView +import com.exptech.dpip.MainActivity +import com.exptech.dpip.R + +/** + * The location picker the launcher opens when a Widget is added or reconfigured. + * Reconfiguration availability depends on the platform and launcher; launchers + * without that entry require removing and adding the Widget again. + * + * The result contract is the framework's, and it is the trap this class is + * careful about: + * + * * **Adding.** The result defaults to `RESULT_CANCELED`, and an Activity that + * never sets it leaves the user with a Widget that was never added, having + * tapped a row. So `RESULT_CANCELED` is set first thing and only replaced + * once the choice has been persisted. + * * **Reconfiguring.** The launcher can reopen the same Activity, and there + * "cancel" means *keep what is configured*. It is + * answered with `RESULT_OK` **without touching the stored instance**, which is + * what "no change" means to the launcher; answering `RESULT_CANCELED` here + * would be asking the launcher to undo the reconfiguration. + * + * It reads the same catalog Dart published for the iOS configuration intent. + * There is no second list of places: a location the app does not have is not + * offerable here, and when the catalog is empty the screen says so and offers to + * open the app rather than inventing an entry. + * + * The chosen address is stored against `appWidgetId` and never read from the + * app's own selected region, which is what keeps a Widget on its own town when + * the user changes the app's Home region. + */ +class WeatherWidgetConfigurationActivity : Activity() { + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private var isReconfigure = false + + override fun onCreate(savedInstanceState: Bundle?) { + // Wallpaper colors only here; API26–30 retains the DPIP teal fallback. + com.google.android.material.color.DynamicColors.applyToActivityIfAvailable(this) + super.onCreate(savedInstanceState) + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false) + setResult(RESULT_CANCELED) + bind(intent) + } + + /** + * A launcher may reuse this `singleTop` Activity for another Widget. + * Validate and rebuild for the latest target, rather than writing a new + * choice to the Widget whose screen happened to be open first. + * + * `setIntent` first, so `getIntent()` — and anything after this that reads it + * — describes the intent that arrived last, which is the Activity contract. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + setResult(RESULT_CANCELED) + bind(intent) + } + + /** + * Reads the target instance from [source] and builds the screen for it. + * + * Shared by `onCreate` and `onNewIntent` because the two must leave + * identical state: a reused instance that only half-updated would report one + * Widget and write another. + */ + private fun bind(source: Intent?) { + appWidgetId = source?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (!isOwnWidget()) { + finish() + return + } + + val store = WidgetStore.open(this) + isReconfigure = store.readInstance(appWidgetId) != null + + setContentView(R.layout.activity_widget_config) + val root = findViewById(R.id.widget_config_root) + androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val bars = insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.systemBars() or + androidx.core.view.WindowInsetsCompat.Type.displayCutout()) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + insets + } + androidx.core.view.ViewCompat.requestApplyInsets(root) + androidx.core.view.WindowCompat.getInsetsController(window, root).apply { + val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == + android.content.res.Configuration.UI_MODE_NIGHT_YES + isAppearanceLightStatusBars = !dark + isAppearanceLightNavigationBars = !dark + } + + findViewById(R.id.widget_config_title).text = + getString(R.string.widget_config_title) + findViewById