Skip to content

Migrate to com.extopy:api/client 0.1.5, with a data/domain/presentation split - #13

Merged
nathanfallet merged 5 commits into
mainfrom
migration/extopy-api-client-0.1.5
Aug 1, 2026
Merged

Migrate to com.extopy:api/client 0.1.5, with a data/domain/presentation split#13
nathanfallet merged 5 commits into
mainfrom
migration/extopy-api-client-0.1.5

Conversation

@nathanfallet

Copy link
Copy Markdown
Member

com.extopy:commons is gone. The backend now publishes an API contract (com.extopy:api) and a
client for it (com.extopy:client), both at 0.1.5, and they are shaped differently enough that the
app could not simply bump a version. This migrates onto them, and takes the opportunity to bring the
project in line with the architecture nutrimaxing-app already runs.

What moved

The API. Models are PostResponse/UserResponse/TimelineResponse on kotlin.uuid.Uuid and
kotlin.time.Instant; reads throw rather than return null; listings take limit/offset/search
rather than a Pagination. The client holds the tokens and refreshes them itself through a
TokenStore the app supplies — RenewTokenUseCase, GetRefreshTokenUseCase and the token
getters/setters had nothing left to do.

kaccelero is dropped. Its 0.7 release removed the client half the app depended on (IAPIClient,
APIException, the auth use cases), and the backend has moved off it too. multiplatform-settings
takes over storage. The generic use case interfaces are gone rather than replaced: a use case is now
a class with operator fun invoke, and only exists where there is a decision to make — the ~30
IXxxUseCase.kt files are deleted. Plain reads go through repositories, which the view models take
directly.

The layers. data/ for how things are fetched and stored, domain/ for what the app is about,
presentation/ for Compose, di/ for the wiring. domain and presentation import neither the API
contract nor Ktor, Room or the settings library — everything crosses over in data/mappers, so the
next change to the contract stops there.

The local cache moves from SQLDelight to Room, and now holds the counters and viewer-relative
flags it used to drop. That is why signing out clears it: those rows belong to the session that
filled them. Closes the "clear cache" TODO logging out was carrying.

The toolchain. Kotlin 2.4, AGP 9.0.1 with com.android.kotlin.multiplatform.library, Gradle 9.1,
Compose Multiplatform 1.11.1, a version catalog, androidApp/iosApp module names, minSdk 26.
Kotlin 2.3+ is not optional — it is what can read the klibs com.extopy:api publishes.

New: running against a local backend

There was no way to do this before, only the two deployed environments.

  • Android — the local flavor points at 10.0.2.2:8080 and is the only flavor allowed to talk
    over cleartext HTTP (./gradlew :androidApp:installLocalDebug).
  • iOS — set EXTOPY_ENV=local in the scheme; ignored in release builds so it cannot ship.

Verified

  • Android production/dev/local, debug and release
  • iOS frameworks (device + simulator), and the Xcode app itself
  • 17 tests in commonTest
  • End to end against a local backend: the authorization code exchange, reading the current user,
    the timeline and its posts all return 200, and the posts render with their author, counters and
    relative timestamps.

Worth knowing before merging

  • Every existing session is invalidated. Token storage changed location and format
    (kaccelero → multiplatform-settings), with no migration. Users will be signed out once on update.
  • com.extopy:api has no Android target, so Android resolves its jvm variant. That works
    through the KMP compatibility rules, but adding androidTarget() backend-side would be cleaner.
  • A refresh-token issue lives in extopy-backend/client, not here: logout() clears the
    TokenStore but not the BearerAuthProvider's in-memory copy, so signing back in within the same
    process reuses the stale token and wipes the fresh one. To be handled with the next backend pass.
  • Two versions are pinned deliberately: lifecycle stays on 2.11.0-beta01 (2.11.0 needs compileSdk
    37 + AGP 9.1), and sentry-cocoa moves to 8.58.2, which is what sentry-kotlin-multiplatform 0.27
    links against.

Review pass

An architecture review ran over the result. Its findings are fixed in da391f1: cache invalidation
on writes (a regression this PR introduced by caching the viewer-relative fields), purgeExpired
never being called, a crash liking the post at the top of a thread, a wrong Dispatchers.IO comment,
a misleading keychain doc, and a handful of dead declarations. 7b49c78 adds the tests that were
missing to catch the first one — checked by reverting the fix and watching them fail.

Pre-existing dette the review surfaced and this PR deliberately leaves alone: going offline reads as
signed out, LogoutUseCase has no UI entry point, the Direct Messages tab opens a blank screen, and
the timeline's suggested-users list is never populated.

The app has to be on Kotlin 2.3 or later to read the klibs that
`com.extopy:api` publishes, which is what pulls the rest of the toolchain
forward with it. This brings the build in line with what nutrimaxing-app
already runs: a version catalog instead of versions written out in each
build script, `com.android.kotlin.multiplatform.library` for the shared
module instead of the old `com.android.library` plus an `android {}` block,
and `androidApp`/`iosApp` as module names.

Room and multiplatform-settings arrive here as dependencies; what actually
uses them is the commit that follows.

Two versions are pinned rather than taken at their latest:

- lifecycle stays on 2.11.0-beta01, since 2.11.0 requires compileSdk 37 and
  AGP 9.1, neither of which is available yet.
- sentry-cocoa moves to 8.58.2, which is what sentry-kotlin-multiplatform
  0.27 links against — the previous 8.4.0 floor resolved to a version
  missing the symbols it needs.

minSdk goes from 21 to 26, which is what lets the core library desugaring go
away.
`com.extopy:commons` is gone. What the backend publishes now is an API
contract (`com.extopy:api`) and a client for it (`com.extopy:client`), and
the two are shaped quite differently from what the app was talking to:

- Models are `PostResponse`/`UserResponse`/`TimelineResponse` rather than
  `Post`/`User`/`Timeline`, on `kotlin.uuid.Uuid` and `kotlin.time.Instant`.
- Reads throw rather than return null, since the client sets
  `expectSuccess`.
- The client holds the tokens and refreshes them itself, through a
  `TokenStore` the app supplies. `RenewTokenUseCase`, `GetRefreshTokenUseCase`
  and the token getters/setters had nothing left to do.
- Listings take `limit`/`offset`/`search` rather than a `Pagination`.

kaccelero is dropped along with it: its 0.7 release removed the client half
the app depended on (`IAPIClient`, `APIException`, the auth use cases), and
the backend has moved off it too. multiplatform-settings takes over storage,
and the generic use case interfaces are gone rather than replaced — a use
case is now a class with `operator fun invoke`, and only exists where there
is a decision to make. Plain reads go through the repositories, which the
view models take directly.

The package layout follows nutrimaxing-app: `data/` for how things are
fetched and stored, `domain/` for what the app is about, `presentation/` for
Compose, `di/` for the wiring. `domain` and `presentation` import neither the
API contract nor Ktor, Room or the settings library — everything crosses over
in `data/mappers`, so the next change to the contract stops there.

The local cache moves from SQLDelight to Room, and now holds the counters and
the viewer-relative flags it used to drop, which is why signing out clears it:
those rows belong to the session that filled them. That closes the "clear
cache" TODO that logging out carried.

Tests move to `commonTest` and cover the two use cases that carry logic, the
token store, and both directions of both mappers.
The README said three lines about KMM. It now says where things live, what
the boundary between the layers actually is, and how to point either platform
at a backend running on your own machine — which is new: there was no way to
do that before, only the two deployed environments.
The cache regression is the one that matters. Caching the viewer-relative
fields — whether you liked a post, whether you follow someone — is what makes
a list draw right on the way back, but nothing dropped the row after the
write that changed them. Liking a post and reopening it inside the minute
showed it unliked again, with the old count. Both writes now delete the row
rather than try to patch it: the server is the only thing that knows what the
count became.

`purgeExpired` was declared and never called, so rows cached once and never
read again stayed forever. It runs when the app opens, which is the moment
those rows are known to be stale.

`onLikeClicked` crashed on the post at the top of a thread: that post is not
one of the replies, so `indexOf` returned -1 and `set(-1, …)` threw. It is
updated on its own now, and the three view models match by id rather than by
position.

On iOS, `Dispatchers.IO` does exist on Kotlin/Native — the comment saying
otherwise was wrong, and SQLite was blocking the CPU-sized pool. It needs
importing by name there, since it is an extension property.

The keychain doc was wrong too: `KeychainSettings(service)` sets
`kSecAttrService`, not an access group, and this app declares no
`keychain-access-groups` entitlement, so nothing was ever shared with it —
an App Group is a different capability. The service is now named for what it
is, and per-environment so the development build cannot read the production
session.

Dropped along the way: the unqualified `Settings` definition neither platform
was injecting, `Picker`, and five dependencies nothing imported
(compose-animation, kotlinx-datetime, koin-compose-viewmodel-navigation,
compottie-resources, ktor-client-mock).
The bug was invisible to the test suite: nothing exercised what happens to a
cached post after it is liked. These four cover it, with fakes for the two
API clients and the two DAOs the repository touches.

Checked by removing the fix again: `forgetsACachedPostOnceItHasBeenLiked` and
`goesToTheNetworkAgainAfterALike` both fail without it.
@nathanfallet
nathanfallet merged commit 69b1926 into main Aug 1, 2026
1 check passed
@nathanfallet
nathanfallet deleted the migration/extopy-api-client-0.1.5 branch August 1, 2026 17:42
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