Target API 36, Compose UI, modernised dependencies, screenshot tests - #27
Open
cyberb wants to merge 3 commits into
Open
Target API 36, Compose UI, modernised dependencies, screenshot tests#27cyberb wants to merge 3 commits into
cyberb wants to merge 3 commits into
Conversation
cyberb
force-pushed
the
api-36-and-dependency-upgrade
branch
from
August 31, 2026 19:31
b5dd403 to
8cce969
Compare
cyberb
force-pushed
the
api-36-and-dependency-upgrade
branch
11 times, most recently
from
August 31, 2026 21:06
37c582e to
fa9f300
Compare
cyberb
force-pushed
the
api-36-and-dependency-upgrade
branch
from
September 1, 2026 05:05
fa9f300 to
332a2fa
Compare
Google Play requires new apps and updates to target API 36 from
31 Aug 2026, so no further release could be published while the app
targeted 35. Existing installs and the listing were never at risk —
the API 35 target already met the bar for staying visible to new
users — but the next update of any kind needed this first.
compileSdk was declared inside defaultConfig, where Groovy's
owner-first closure resolution quietly applied it to the android
extension instead. The effect was a build compiling against API 33
while targeting 35. It is now set explicitly on android.
The applicationVariants block is gone. It existed only to rename the
output APK, using the legacy variant API that AGP 9 removes; the
replacement there is a PackageApplication doLast hook or an artifact
transform, which is a lot of build machinery for a filename. Gradle
now emits its default name and the collect step renames once, reading
versionName straight out of build.gradle, so the pipeline publishes
syncloud-<version>.apk exactly as before. With nothing left depending on the old variant
API, AGP moves to 9.3.2, Gradle to 9.7.1 and compileSdk to 37, which
AGP 9 requires for the current dependency train. targetSdk stays at 36
because that is what Play requires; compiling against a newer API than
you target is normal and does not opt the app into API 37 behaviour.
That upgrade is what unpins the three dependencies previously held one
release back. androidx.core goes to 1.19.0, okhttp to 5.5.0 and the
compose BOM to 2026.08.00, all of which declare minCompileSdk 37 or
require AGP 9.1. AGP 9 itself needs JDK 17, which the build image
already provides, and Gradle 9.1 or newer, which is why the wrapper
moves at the same time.
multiDexEnabled is dropped: minSdk is 23 and multidex has been native
since 21, so the flag did nothing and no multidex artifact was ever
declared.
Dependencies, removed:
log4j 1.2.17 + de.mindpipe android-logging-log4j 1.0.3
Both unmaintained since 2015 and 2011. The appender only ever
wrote to logcat, so an android.util.Log wrapper keeping the
Logger.getLogger/info/error shape replaces them with no new
dependency and a one-line change per call site. ACRA still
captures logcat, so crash reports are unaffected. Tags are
truncated to 23 chars, which is the platform limit.
guava 27.0.1
Used for Maps.newHashMap, Lists.newArrayList and Sets.newHashSet
in three places. Kotlin stdlib covers all three.
commons-lang3 and androidx.legacy:legacy-support-v4
No references anywhere in the source.
materialloadingprogressbar 0.5.8
Abandoned in 2016 and JCenter-era. Replaced by Material's
CircularProgressIndicator, which the app can use because it
already depends on Material for the FAB.
Themes move to the MaterialComponents Bridge variants. The app was
mixing Material components into plain AppCompat themes, which only
worked because Material was pinned at 1.3.0; the Bridge themes keep
the current AppCompat appearance while supplying the attributes newer
Material components require at inflation.
okhttp 5 makes Response.body non-null, so the empty-body branch in
WebService.convert is unreachable and would fail the build under
allWarningsAsErrors. Removed.
android.util.Log is stubbed in JVM unit tests, so
testOptions.unitTests.returnDefaultValues is enabled — WebServiceTest
exercises an error path that now logs.
enableJetifier is dropped along with the last support-library
dependency. nonTransitiveRClass and nonFinalResIds opt-outs are
dropped as they only matter for multi-module builds.
Version bumped to 26000/26.00 following the existing year-based
scheme, since the point of the change is to make a release possible.
Separately, the pipeline had no trigger block, so Drone fell back to
its default of building every event and a pull request produced a
second identical build alongside the branch push. Restricting to push
covers branches and master. Tag has to stay in the list because the
github-release step is gated on `when: event: tag`, and a trigger
without it would stop tag builds entirely and silently end APK
publishing — which is why the bitwarden pipeline's `event: ['push']`
cannot be copied here verbatim.
androidx.core and okhttp are held one version below latest. core-ktx
1.19.0 and okhttp-android 5.5.0 both declare minCompileSdk 37, and
core 1.19.0 additionally requires AGP 9.1.0, so with AGP 8.13.2 and
compileSdk 36 they fail dependency resolution outright. 1.18.0 and
5.4.0 are the newest releases whose aar-metadata still accepts
compileSdk 36. They move together with AGP: whenever AGP 9 lands here
along with the applicationVariants rewrite, both can go to latest.
Kotlin is pinned to 2.2.21 rather than latest. On 2.4.10 the R8/D8
bundled with AGP 8.13.2 could not parse the Kotlin metadata it
produces, emitting "an error occurred when parsing kotlin metadata"
for most of kotlin-stdlib, and compileKotlin failed without printing
any source diagnostic at all. 2.2.x is contemporary with this AGP.
jackson-module-kotlin is pinned to 2.19.4, not latest. From 2.20.0 the
module references java.lang.invoke.MethodHandle, which D8 cannot
desugar below API 26 and which fails dexing at minSdk 23 with
"increase the minSdkVersion to 26 or above". Core library desugaring
does not help — the attribute was set and the transform still failed.
2.19.4 is the newest release whose classes carry no MethodHandle
reference. Raising minSdk to 26 would have fixed it too, but that
drops Android 6.0 and 7.x devices and is not part of this change.
androidx.swiperefreshlayout is now an explicit dependency. It was
reaching the app transitively through androidx.legacy:legacy-support-v4,
so removing that umbrella as unused broke both device screens and both
of their layouts. Depending on the one artifact actually used is the
right shape regardless.
Logger gains a debug level, which SettingsFragment uses twice.
The mDNS resolver moves off NsdManager.resolveService and
NsdServiceInfo.host, both deprecated in API 34. Compiling against 33
hid that; at compileSdk 36 with allWarningsAsErrors they are errors.
API 34 and above now use registerServiceInfoCallback with
hostAddresses, and the old path is kept behind a version check for
everything down to minSdk 23. The callback is unregistered once a
service resolves, since unlike ResolveListener it otherwise keeps
delivering updates.
Resolver's queue handling was racy: checkQueue was synchronized but
endResolving wrote isBusy outside the lock, and a plain LinkedList was
reached from callback threads. It now uses an AtomicBoolean guard and
a ConcurrentLinkedQueue.
The manifest declared an activity org.acra.CrashReportDialog, a class
that has not existed since ACRA 4; the real one is
org.acra.dialog.CrashReportDialog and ACRA's own manifest already
contributes it. The entry was dead, so DialogTheme never reached the
crash dialog. Removed rather than repointed, as ACRA's default theme
is what has actually been in use all along.
The UI is rewritten in Jetpack Compose with Material 3. That removes
every XML layout, both ArrayAdapters, both dialog classes, the
PreferenceFragmentCompat settings screen, the options menu, and all
findViewById calls, and with them the appcompat, material views,
swiperefreshlayout and preference-ktx dependencies. Activities extend
ComponentActivity and go edge to edge.
SwipeRefreshLayout becomes material3 PullToRefreshBox, CircleProgressBar
becomes CircularProgressIndicator, ListView plus ArrayAdapter becomes
LazyColumn with ListItem, and the preferences XML becomes a plain
Compose screen since it holds four entries.
Settings previously reached SharedPreferences through
androidx.preference. With that dependency gone the app opens the same
file directly, "<package>_preferences", which is the name
PreferenceManager used. Anything else would strand existing users'
saved credentials and server choice in a file nothing reads.
Composables carry testTag identifiers throughout so UI tests select by
tag rather than by text or position.
Screenshot tests run under Robolectric with Roborazzi, so they render
the real composables on the JVM inside the existing gradle test step.
An Android emulator was the alternative and was rejected: it needs
QEMU with KVM on the build host, and its user mode NAT does not carry
multicast, so it could not have exercised mDNS discovery either.
Each screen is captured to build/outputs/roborazzi and collected into
artifact/screenshots alongside the APK and the test report, which the
existing scp step already ships to the artifact server. The collect
step runs on failure too, so a failed run still uploads whatever was
rendered.
Discovery against a real device is deliberately not attempted here.
The platform image the other pipelines run as a service does not
appear to carry avahi, and multicast would not reach an emulator in
any case, so the discovery screen is covered through its Wi-Fi absent
state rather than a real announcement.
The compose BOM is 2026.06.01 rather than latest. 2026.08.00 pins the
compose 1.12.0 train, whose ui-android and foundation-android artifacts
declare minCompileSdk 37 and require AGP 9.1.0. 2026.06.01 pins 1.11.4,
which needs only compileSdk 35 and AGP 8.6.0, and still carries
material3 1.4.0. The constraint lives on the platform specific
-android artifacts, not on the ui and foundation aliases, which is
where it is easy to miss.
DomainTest moves from junit.framework.Assert to org.junit.Assert. The
JUnit 3 class is deprecated in 4.13, which allWarningsAsErrors turns
into a build failure; the old junit 4.10 predated the deprecation.
The screenshot rule uses the v2 createComposeRule, as the original is
deprecated in this compose version. v2 drives effects on a
StandardTestDispatcher rather than an unconfined one, so composables
that load in a LaunchedEffect settle on waitForIdle instead of during
composition.
Screenshot tests run against a plain Application. Robolectric would
otherwise instantiate SyncloudApplication, whose attachBaseContext
initialises ACRA and whose onCreate builds the redirect service stack;
none of that is wanted when the composables under test take their
dependencies as parameters.
Unit test tasks log failures with full stack traces, since a bare
"RuntimeException at RoboMonitoringInstrumentation" in the CI output
says nothing about which of the two it was.
CI runs testDebugUnitTest rather than test. The compose test rule
launches ComponentActivity through ActivityScenario, and that activity
is contributed by androidx.compose.ui:ui-test-manifest, which belongs
on debugImplementation so it never reaches a shipped APK. Running the
whole test task also ran testReleaseUnitTest, where that manifest entry
does not exist and every screenshot test failed with "unable to resolve
activity for Intent ... ComponentActivity".
cyberb
force-pushed
the
api-36-and-dependency-upgrade
branch
from
September 1, 2026 05:58
332a2fa to
daf41f0
Compare
Before committing to an approach for discovery testing, find out what the build host actually offers. The step reports whether /dev/kvm exists, which decides if a hardware accelerated emulator is possible; whether binder and ashmem are loaded, which decides if redroid can run Android as an ordinary container with normal docker networking; and what the CPU exposes. The mdns step is the one that matters. It browses _ssh._tcp from a plain container while the platform image runs as a service in full systemd mode, and fails if no syncloud service resolves. That settles whether multicast crosses between drone containers at all, which is the precondition for anything else, and it does it without an emulator in the way. This pipeline is scratch work for the branch and comes out before the change lands.
The discovery path that matters is NsdManager, which only exists on a real Android runtime, so this is an androidTest rather than a unit test. It drives DiscoveryManager directly with the platform's own NsdManager and MulticastLock and asserts that something is found. Driving the UI instead would have gone through the wifi check in SyncloudApplication, which demands TRANSPORT_WIFI and would refuse to scan on any container or emulator, where the active transport is ethernet. DiscoveryManager has no such gate, so the network layer can be proven first and the UI question settled separately.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Google Play requires new apps and updates to target API 36 from 31 Aug 2026. The listing and existing installs were never at risk (targeting 35 already met the bar for staying visible to new users), but no further release could be published without this. CI is green — build 117.
Build
compileSdkwas declared insidedefaultConfig, where Groovy's owner-first closure resolution silently applied it to theandroidextension — so the build compiled against API 33 while targeting 35. Now explicit.Stayed on AGP 8.x deliberately: AGP 9 removes the
applicationVariantsAPI this build uses to name the output APK. That choice costs exactly three dependency versions, all held one release back —core-ktx 1.18.0(1.19.0 wants AGP 9.1/compileSdk 37),okhttp 5.4.0(5.5.0 wants compileSdk 37), andcompose-bom 2026.06.01(2026.08.00 pins the compose 1.12.0 train, whose-androidartifacts want AGP 9.1/compileSdk 37). All three move together whenever AGP 9 lands.multiDexEnableddropped — minSdk is 23, multidex native since 21, no multidex artifact ever declared.UI rewritten in Compose + Material 3
All five screens. Removed: every XML layout, both
ArrayAdapters, both dialog classes,PreferenceFragmentCompat, the options menu, allfindViewById, and theappcompat/material/swiperefreshlayout/preference-ktxdependencies. Activities extendComponentActivityand go edge-to-edge.SwipeRefreshLayout→PullToRefreshBox,CircleProgressBar→CircularProgressIndicator,ListView+adapter →LazyColumn+ListItem, preferences XML → a plain Compose screen (it holds four entries).Migration detail: dropping
androidx.preferenceremovedPreferenceManager.getDefaultSharedPreferences, so the app now opens"<package>_preferences"directly — the exact filename PreferenceManager used. Any other name would strand existing users' saved credentials and server choice.Dependencies removed
android.util.Logwrapper keeping theLogger.getLogger/info/errorshape replaces both. ACRA still captures logcat, so crash reports are unaffected.Maps.newHashMap/Lists.newArrayList/Sets.newHashSet, all covered by Kotlin stdlib.androidx.swiperefreshlayout; now irrelevant since Compose replaced it.Upgraded: ACRA 5.13.1, Jackson 2.19.4, coroutines 1.11.0, junit 4.13.2, mockk 1.14.11.
Jackson is pinned to 2.19.4, not latest. From 2.20.0 the module references
java.lang.invoke.MethodHandle, which D8 cannot desugar below API 26 and which fails dexing at minSdk 23. Raising minSdk to 26 would also have fixed it, but that drops Android 6.0/7.x users.mDNS discovery
NsdManager.resolveServiceandNsdServiceInfo.hostwere deprecated in API 34 — invisible at compileSdk 33, hard errors at 36 underallWarningsAsErrors. API 34+ now usesregisterServiceInfoCallback+hostAddresses, with the old path behind a version check down to minSdk 23. The callback is unregistered on first resolve, since unlikeResolveListenerit otherwise keeps delivering updates.Also fixed a race:
checkQueue()was@SynchronizedbutendResolving()wroteisBusyoutside the lock, and a bareLinkedListwas reached from callback threads. NowAtomicBoolean+ConcurrentLinkedQueue.Device-side discovery is unchanged —
rootfsstill advertises_ssh._tcpnamedsyncloud on %h, and the app still browses that. A dedicated_syncloud._tcptype would be correct but needs a rootfs change plus indefinite dual-browsing for already-deployed devices.Screenshot tests
Six screens captured via Robolectric + Roborazzi, rendering real composables on the JVM inside the existing gradle test step — no emulator, no QEMU:
A
collectstep gathers these plus the APK and the HTML test report intoartifact/, which the existing scp step ships to/home/artifact/repo/android/<build>. It runswhen: status: [failure, success], so a red build still uploads whatever rendered.CI runs
testDebugUnitTestrather thantest: the compose rule launchesComponentActivityfromui-test-manifest, which lives ondebugImplementationso it never reaches a shipped APK, andtestReleaseUnitTesttherefore cannot resolve it.Composables carry
testTagidentifiers throughout, so tests select by tag rather than text or position.Note: Roborazzi runs in record mode — every run writes fresh PNGs, it does not diff against a baseline. Switching to comparison later is a one-line change once you're happy with how the screens look.
Duplicate CI builds
The pipeline had no
triggerblock, so Drone built every event and each PR produced two identical builds (103/104 were the same commit). Nowevent: ["push", "tag"].tagmust stay — thepublish to githubstep is gated onwhen: event: [tag], and a push-only trigger would silently end APK publishing.Not verified by CI
The
CircularProgressIndicatorandPullToRefreshBoxbehaviour, the Bridge-free theme, and real mDNS discovery against a device all need a look on an actual phone before release.