Skip to content

Target API 36, Compose UI, modernised dependencies, screenshot tests - #27

Open
cyberb wants to merge 1 commit into
masterfrom
api-36-and-dependency-upgrade
Open

Target API 36, Compose UI, modernised dependencies, screenshot tests#27
cyberb wants to merge 1 commit into
masterfrom
api-36-and-dependency-upgrade

Conversation

@cyberb

@cyberb cyberb commented Aug 31, 2026

Copy link
Copy Markdown
Member

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

before after
compileSdk 33 (see below) 36
targetSdk 35 36
AGP 8.5.2 8.13.2
Gradle 8.7 8.14.5
Kotlin 2.0.10 2.2.21
version 24000 / 24.00 26000 / 26.00

compileSdk was declared inside defaultConfig, where Groovy's owner-first closure resolution silently applied it to the android extension — so the build compiled against API 33 while targeting 35. Now explicit.

Stayed on AGP 8.x deliberately: AGP 9 removes the applicationVariants API 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), and compose-bom 2026.06.01 (2026.08.00 pins the compose 1.12.0 train, whose -android artifacts want AGP 9.1/compileSdk 37). All three move together whenever AGP 9 lands.

multiDexEnabled dropped — 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, all findViewById, and the appcompat / material / swiperefreshlayout / preference-ktx dependencies. Activities extend ComponentActivity and go edge-to-edge.

SwipeRefreshLayoutPullToRefreshBox, CircleProgressBarCircularProgressIndicator, ListView+adapter → LazyColumn+ListItem, preferences XML → a plain Compose screen (it holds four entries).

Migration detail: dropping androidx.preference removed PreferenceManager.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

  • log4j 1.2.17 + de.mindpipe:android-logging-log4j 1.0.3 — unmaintained since 2015 and 2011. The appender only wrote to logcat, so a small android.util.Log wrapper keeping the Logger.getLogger/info/error shape replaces both. ACRA still captures logcat, so crash reports are unaffected.
  • guava 27.0.1 — three uses of Maps.newHashMap/Lists.newArrayList/Sets.newHashSet, all covered by Kotlin stdlib.
  • commons-lang3 — no references anywhere.
  • androidx.legacy:legacy-support-v4 — was transitively supplying androidx.swiperefreshlayout; now irrelevant since Compose replaced it.
  • materialloadingprogressbar 0.5.8 — abandoned 2016, JCenter-era.

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.resolveService and NsdServiceInfo.host were deprecated in API 34 — invisible at compileSdk 33, hard errors at 36 under allWarningsAsErrors. API 34+ now uses registerServiceInfoCallback + hostAddresses, with the old path behind a version check down to minSdk 23. The callback is unregistered on first resolve, since unlike ResolveListener it otherwise keeps delivering updates.

Also fixed a race: checkQueue() was @Synchronized but endResolving() wrote isBusy outside the lock, and a bare LinkedList was reached from callback threads. Now AtomicBoolean + ConcurrentLinkedQueue.

Device-side discovery is unchangedrootfs still advertises _ssh._tcp named syncloud on %h, and the app still browses that. A dedicated _syncloud._tcp type 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:

artifact/screenshots/
  01-auth.png  02-credentials.png  03-devices-saved.png
  04-devices-saved-empty.png  05-discovery-no-wifi.png  06-settings.png

A collect step gathers these plus the APK and the HTML test report into artifact/, which the existing scp step ships to /home/artifact/repo/android/<build>. It runs when: status: [failure, success], so a red build still uploads whatever rendered.

CI runs testDebugUnitTest rather than test: the compose rule launches ComponentActivity from ui-test-manifest, which lives on debugImplementation so it never reaches a shipped APK, and testReleaseUnitTest therefore cannot resolve it.

Composables carry testTag identifiers 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 trigger block, so Drone built every event and each PR produced two identical builds (103/104 were the same commit). Now event: ["push", "tag"]. tag must stay — the publish to github step is gated on when: event: [tag], and a push-only trigger would silently end APK publishing.

Not verified by CI

The CircularProgressIndicator and PullToRefreshBox behaviour, the Bridge-free theme, and real mDNS discovery against a device all need a look on an actual phone before release.

@cyberb
cyberb force-pushed the api-36-and-dependency-upgrade branch from b5dd403 to 8cce969 Compare August 31, 2026 19:31
@cyberb cyberb mentioned this pull request Aug 31, 2026
@cyberb cyberb changed the title Target API 36 and modernise dependencies Target API 36, modernise dependencies, build only on push and tag Aug 31, 2026
@cyberb
cyberb force-pushed the api-36-and-dependency-upgrade branch 11 times, most recently from 37c582e to fa9f300 Compare August 31, 2026 21:06
@cyberb cyberb changed the title Target API 36, modernise dependencies, build only on push and tag Target API 36, Compose UI, modernised dependencies, screenshot tests Aug 31, 2026
@cyberb
cyberb force-pushed the api-36-and-dependency-upgrade branch 5 times, most recently from 87b2c2d to 516f115 Compare September 4, 2026 08:52
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, at 37, with targetSdk
left at 36 because that is what Play requires; compiling against a
newer API than you target does not opt the app into its behaviour.

The applicationVariants block is gone. It existed only to rename the
output APK through the legacy variant API that AGP 9 removes, and the
replacement there is a PackageApplication hook or an artifact
transform, which is a lot of machinery for a filename. Gradle emits
its default name and the collect step renames once, reading
versionName out of build.gradle, so the pipeline still publishes
syncloud-<version>.apk. Removing it is what allowed AGP 9, which in
turn unpinned androidx.core, okhttp and the compose BOM; all three had
been held a release back because their -android artifacts require
AGP 9.1 or compileSdk 37.

AGP 9 compiles Kotlin itself and rejects an explicit
org.jetbrains.kotlin.android plugin, so that is removed and the
toolchain and compiler options move to the top level kotlin
extension. The module DSL uses explicit assignment throughout.

multiDexEnabled is dropped: minSdk is 23 and multidex has been native
since 21, so the flag did nothing.

Dependencies, removed:

  log4j 1.2.17 + de.mindpipe android-logging-log4j 1.0.3
    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. ACRA still captures logcat, so crash reports are
    unaffected. Tags are truncated to 23 chars, the platform limit.
  guava 27.0.1
    Three uses of Maps.newHashMap, Lists.newArrayList and
    Sets.newHashSet. Kotlin stdlib covers all three.
  commons-lang3
    No references anywhere in the source.
  androidx.legacy:legacy-support-v4
    Was transitively supplying androidx.swiperefreshlayout, which the
    device screens used; Compose replaces it outright.
  materialloadingprogressbar 0.5.8
    Abandoned in 2016 and JCenter-era.

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. 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.

The UI is rewritten in Jetpack Compose with Material 3, removing every
XML layout, both ArrayAdapters, both dialog classes, the
PreferenceFragmentCompat settings screen, the options menu and all
findViewById calls, and with them appcompat, material views,
swiperefreshlayout and preference-ktx. Activities extend
ComponentActivity and go edge to edge. Settings previously reached
SharedPreferences through androidx.preference; with that 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.

The mDNS resolver moves off NsdManager.resolveService and
NsdServiceInfo.host, deprecated in API 34 and hard errors at
compileSdk 36 under allWarningsAsErrors. API 34 and above use
registerServiceInfoCallback with hostAddresses, and the old path stays
behind a version check down to minSdk 23. The callback is unregistered
once a service resolves, since unlike ResolveListener it otherwise
keeps delivering updates. Resolver's queue was racy — checkQueue was
synchronized but endResolving wrote isBusy outside the lock, and a
plain LinkedList was reached from callback threads — and now uses an
AtomicBoolean guard and a ConcurrentLinkedQueue.

The manifest declared 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, so DialogTheme never
reached the crash dialog.

Testing has two halves. Screenshots of all six screens render under
Robolectric with Roborazzi, on the JVM inside the existing gradle test
step, and land on the artifact server next to the APK. CI runs
testDebugUnitTest rather than test, because the compose rule launches
ComponentActivity from ui-test-manifest, which belongs on
debugImplementation and so does not exist in the release variant.

Discovery is tested for real. The platform image runs as a drone
service in full systemd mode with avahi advertising, redroid runs
Android 14 as a sibling container, and an instrumented test drives
DiscoveryManager through the platform's own NsdManager. redroid rather
than an emulator because the emulator puts the guest behind qemu user
mode NAT, which does not carry multicast, so mDNS could never reach
it; redroid runs on the host kernel with ordinary docker networking.
It needs binder_linux, which ships with the 6.8 kernel and is loaded
and mounted on the build host. Both services need privileged, so the
repo must stay trusted in drone.

Device side discovery is unchanged: rootfs still advertises _ssh._tcp
named "syncloud on %h" and the app still browses that.

Separately, the pipeline had no trigger block, so drone built 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 because the github-release step is gated on it, and a
trigger without it would silently end APK publishing.
@cyberb
cyberb force-pushed the api-36-and-dependency-upgrade branch from 516f115 to 01422f2 Compare September 4, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant