Skip to content

docs: план перехода на Kotlin Multiplatform (Android, iOS, macOS, JVM-desktop) - #56

Closed
andrikeev wants to merge 22 commits into
masterfrom
claude/kmp-implementation-plan-x8tkci
Closed

andrikeev wants to merge 22 commits into
masterfrom
claude/kmp-implementation-plan-x8tkci

Conversation

@andrikeev

Copy link
Copy Markdown
Owner

Что это

Документ docs/kmp-migration-plan.md — поэтапный план перехода Flow на Kotlin Multiplatform с таргетами Android, iOS, macOS, JVM-desktop. UI шарится через Compose Multiplatform.

План основан на инвентаризации текущей кодовой базы (21 core-модуль + 15 feature-модулей, Hilt/Ktor/Room/Coil/Orbit/Navigation3).

Ключевые выводы

  • Замена БД не нужна — Room 2.8 уже поддерживает KMP (BundledSQLiteDriver).
  • Многое уже multiplatform в текущих версиях: Ktor-client, Coil 3, Orbit 10, lifecycle-viewmodel, kotlinx-(serialization/datetime/coroutines).
  • Реальные блокеры: Hilt → Koin, jsoup → Ksoup (~10 файлов парсера rutracker), Context-зависимые сервисы / secure storage / WorkManager / notifications / downloads через expect/actual, и перевод UI на Compose Multiplatform.
  • Навигация (Navigation3 → CMP Navigation) вынесена в отдельное архитектурное решение.

Структура плана

  • §2 — таблица готовности всех модулей
  • §3 — решения по библиотекам (что уже KMP)
  • §4–5 — целевые source sets и новые конвеншн-плагины buildSrc
  • §6 — стратегия навигации
  • §7 — поэтапный план (0–9), снизу вверх, с зелёной Android-сборкой на каждом шаге
  • §8 — риски и митигации

Это документ для обсуждения — реальные изменения кода последуют отдельными PR по этапам.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 21 commits June 21, 2026 20:45
…VM-desktop)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Stage 0 (infrastructure):
- Add flow.kmp.library convention plugin targeting Android, JVM (desktop),
  iOS and macOS. Under AGP 9.0+ the Kotlin Multiplatform plugin is no longer
  compatible with com.android.library, so it uses the dedicated
  com.android.kotlin.multiplatform.library plugin (kotlin { androidLibrary {} }).
- Make flow.kotlin.serialization KMP-aware (commonMainImplementation vs
  implementation depending on whether the multiplatform plugin is applied).
- Add the KMP Android library plugin marker to the buildSrc classpath.

Stage 1 (leaf modules -> KMP commonMain):
- core:models, core:common, core:network:api, core:auth:api, core:work:api
  moved from src/main/kotlin to src/commonMain/kotlin and switched to
  flow.kmp.library. These modules are pure Kotlin (kotlinx only), so they
  compile unchanged in commonMain.

Apple targets are declared but only built on Apple hosts; the Android CI
tasks (lintDebug, testDebugUnitTest, assembleDebug/Release) never trigger
their compile/test tasks. Android consumers resolve the Android variant and
the JVM consumer (core:network:rutracker / proxy) resolves the JVM variant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
- Convert core:network:rutracker to flow.kmp.library.
- Replace jsoup (JVM-only) with Ksoup (com.fleeksoft.ksoup, KMP port) across
  all parser use cases.
- Make the parsers commonMain-safe by removing JVM-only APIs:
  - java.util.regex.Pattern -> kotlin.text.Regex (LoginUseCase,
    WithFormTokenUseCase, Utils.getTags)
  - java.net.URI query parsing -> pure-Kotlin substring/split (Utils.parseUrl)
  - String.format("%.1f") -> manual rounding (Utils.formatSize)
  - java.util.Locale -> Char.titlecase() (ParsePostUseCase)
  - Element.tag().name -> Element.tagName()
- Keep the Windows-1251 form encoding (java.net.URLEncoder) and the Ktor HTTP
  client impl in a nonNativeMain source set shared by Android + JVM only;
  RuTrackerInnerApiImpl and RuTrackerApiFactory live there. Native engines /
  charset handling are deferred to the networking stage.

commonMain is verified free of java.*/javax.*; the Android CI build compiles
commonMain (exercising the Ksoup API) and nonNativeMain via the Android target.
JVM consumers (proxy) resolve the JVM variant, which includes nonNativeMain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
…perty

Ksoup exposes getWholeText() as a function, not a 'wholeText' property like
jsoup. Fixes the commonMain compilation error in ParsePostUseCase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Pilot for the DI migration on a single, self-contained module. The DI graph
cannot be partially migrated, so this uses the strangler pattern:

- core:dispatchers no longer depends on Hilt: removed @Inject/@BINDS, dropped
  the flow.android.hilt plugin. DispatchersImpl is now a plain class with a
  framework-agnostic createDispatchers() factory.
- core:dispatchers exposes a Koin module (dispatchersModule) — the target DI
  wiring for the multiplatform graph (not yet bootstrapped at runtime).
- The Android app keeps working via a thin Hilt bridge (:app .../di/
  DispatchersModule) that @provides Dispatchers through the same factory.
  Existing Hilt consumers are unchanged.
- Declare kotlinx-coroutines-core explicitly (was pulled in transitively via
  hilt-android, which is now gone).

DispatchersModule/DispatchersImpl are referenced only within core:dispatchers
and tests use TestDispatchers directly, so removing the Hilt binding is safe.
Verified by the Android CI build, which assembles the full Hilt graph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Apply the strangler pattern from the dispatchers pilot to core:logger:
- Remove Hilt from core:logger (drop flow.android.hilt, remove @Inject/@BINDS).
  LoggerFactoryImpl (debug + release variants) become plain classes; a
  framework-agnostic createLoggerFactory() resolves the variant-specific impl.
- Expose a Koin module (loggerModule) as the target multiplatform wiring.
- Bridge LoggerFactory into the Android Hilt graph from :app (.../di/
  LoggerModule). Existing Hilt consumers (feature ViewModels) are unchanged.

LoggerModule/LoggerFactoryImpl are referenced only within core:logger; tests use
TestLoggerFactory directly, and there are no @TestInstallIn replacements, so
removing the Hilt binding is safe. Verified by the Android CI build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Continue the strangler DI migration with a module that has constructor
dependencies and a dual binding:

- core:auth:impl no longer depends on Hilt (drop flow.android.hilt, remove
  @Inject/@singleton). AuthServiceImpl is a plain class; createAuthService()
  builds it from NetworkApi + PreferencesStorage.
- One stateful instance backs both AuthService and TokenProvider. The Koin
  module and the Hilt bridge both expose TokenProvider derived from the same
  singleton AuthService instance, preserving the previous @singleton semantics.
- Bridge in :app (.../di/AuthModule) injects NetworkApi + PreferencesStorage
  from the existing Hilt graph and provides both interfaces.
- Declare kotlinx-coroutines-core explicitly (was transitive via hilt-android).

AuthServiceImpl/AuthModule are internal and referenced only within the module;
no @TestInstallIn replacements exist. Verified by the Android CI build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
First Context-dependent infra module via the strangler pattern:
- core:preferences no longer depends on Hilt (drop flow.android.hilt, remove
  @Inject/@Singleton/@ApplicationContext). PreferencesStorageImpl and
  SharedPreferencesFactoryImpl are plain classes.
- createPreferencesStorage(context, dispatchers) wires the internal encrypted
  SharedPreferences factory, so the internal SharedPreferencesFactory needs no
  DI binding.
- Bridge in :app (.../di/PreferencesModule) provides PreferencesStorage,
  injecting @ApplicationContext Context + Dispatchers from the Hilt graph.
- Declare kotlinx-coroutines-core explicitly (was transitive via hilt-android).

No Koin module yet: this module is Android-specific (EncryptedSharedPreferences)
and will get a multiplatform impl (DataStore/Keychain) + Koin module when
converted to KMP. SecureStorageModule/PreferencesStorageImpl were internal and
unreferenced elsewhere. Verified by the Android CI build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Two more Context-only infra modules via the strangler pattern:
- Drop flow.android.hilt; remove @Inject/@Singleton/@ApplicationContext.
  NotificationServiceImpl and DownloadServiceImpl become plain classes.
- createNotificationService(context) / createDownloadService(context) factories.
- Bridges in :app (.../di/NotificationsModule, DownloadsModule) provide the
  services with @ApplicationContext Context from the Hilt graph.

Both are Android-specific (platform notifications / DownloadManager) and will get
multiplatform abstractions when converted to KMP, hence no Koin module yet. The
impls/modules are internal and unreferenced elsewhere. Verified by the Android CI build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Removing flow.android.hilt also removed androidx.core, which was pulled in
transitively via hilt-android. core:downloads (getSystemService ktx) and
core:notifications (NotificationCompat) use androidx.core.* directly, so add an
explicit androidx.core.ktx dependency to both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Phased plan (Ф0-Ф7) to bootstrap Koin in :app and migrate the remaining,
interconnected modules (data, network:impl, domain, features, work:impl) off
Hilt, with a Koin-owns-instances + inverse-bridge coexistence strategy, ViewModel
(koinViewModel) and @HiltWorker (KoinWorkerFactory) migration details, and
checkModules/verify in CI to validate the graph at test time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
- Record the agreed decisions in docs/kmp-di-bigbang-plan.md (use Koin
  Annotations; data/domain as separate PRs; work:impl as its own step; no
  intermediate dual-container period in prod) plus the dependency-ordered
  migration sequence.
- Add koin-android to :app and start Koin (empty graph) before super.onCreate,
  so Koin and Hilt coexist and later inverse-bridges can resolve Koin-owned
  instances. No ownership changes yet (zero runtime risk).
- Add a Koin smoke test (app unit test) asserting the dependency-free infra
  modules (dispatchers, logger) resolve; it will grow into a verify()/checkModules
  graph check once Koin Annotations land.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Move the 5 dependency-light infra modules to Koin ownership:
- Add Koin modules for preferences/notifications/downloads (Context + Dispatchers
  resolved from the Koin graph via androidContext()/dispatchersModule); dispatchers/
  logger already had Koin modules.
- Register all 5 modules in startKoin (FlowApplication).
- Convert the 5 Hilt bridges in :app to inverse-bridges: Koin owns the single
  instance, Hilt consumers read it via GlobalContext.get().get(). This guarantees a
  single instance regardless of Hilt/Koin consumer mix (important for stateful ones
  like the downloads cache).

auth:impl stays on a direct Hilt bridge for now (its NetworkApi dependency is not in
Koin yet); it migrates after network:impl. Koin is started before super.onCreate so
inverse-bridges resolve. The Koin smoke test covers dispatchers/logger; the Context
modules are compile-validated (instantiation needs Android, out of scope for unit tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Bottom of the data stack (depends only on Context):
- Drop flow.android.hilt from core:database (keep KSP + Room). Replace the Hilt
  DatabaseModule/DaosModule with a Koin databaseModule providing AppDatabase
  (Context via androidContext()) and the 8 DAOs.
- Register databaseModule in startKoin.
- Inverse-bridge in :app exposes the 8 DAOs to core:data (still on Hilt) so a
  single Room instance is owned by Koin. These bridges are removed when core:data
  migrates to Koin (next step).

Coroutines stay available via room-ktx (not via hilt-android). core:database has no
@Inject classes; the modules were the only Hilt usage. Verified by the Android CI build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Migrate the 9 data repositories to Koin (services stay on Hilt for now since they
depend on NetworkApi/AuthService, which migrate with network:impl):
- De-Hilt the 9 repository impls (remove @Inject); they depend only on DAOs,
  PreferencesStorage and LoggerFactory, all already in Koin.
- repositoryModule wires them with singleOf(::Impl) bind Interface.
- DataModule keeps only the 7 service @BINDS.
- Replace the 8 DAO inverse-bridges in :app with 9 repository inverse-bridges
  (DAOs are now consumed directly by the Koin repositories; repositories are read
  from Koin by the remaining Hilt consumers — data services, domain,
  network:impl ProxyController -> SettingsRepository).
- Add RepositoryModuleTest.verify(): statically validates the repository graph at
  test time in CI (the runtime DI safety net), declaring DAOs/PreferencesStorage/
  LoggerFactory as extra types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Record completed phases (Ф0, Ф1, Ф2a, Ф2b) and clarify that data services +
network:impl + auth:impl migrate together as a cluster (mutual NetworkApi <->
SettingsRepository dependency), plus the CI verification limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Migrate the two Context-only data services (ConnectionService, StoreService) to
Koin, leaving the network-backed services (Favorites/Forum/Search/Topic/Torrent)
on Hilt until network:impl + auth migrate together:
- De-Hilt ConnectionServiceImpl/StoreServiceImpl (remove @Inject/@ApplicationContext).
- serviceModule wires them with singleOf; ServiceModuleTest.verify() validates the
  graph (Context/Dispatchers/LoggerFactory as extra types).
- DataModule keeps the 5 network-backed @BINDS.
- Inverse-bridge in :app exposes both services to the remaining Hilt consumers
  (domain use cases).

network:impl (DI cycle Provider<OkHttpClient>, multibinding, OkHttp/Coil) is
deferred per the agreed approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Migrate the network/auth cluster to Koin:
- core:network:impl off Hilt: de-Hilt all impls; networkModule wires them with
  singleOf/single. The OkHttpClient <-> ProxyController cycle is broken by changing
  ProxyControllerImpl to take a lazy () -> OkHttpClient (was javax Provider).
  Interceptors are collected via getAll<Interceptor>(); the debug Chucker
  interceptor is contributed by networkDebugModules() (debug/release source sets,
  empty in release), replacing the Hilt @Multibinds + @IntoSet.
- core:auth:impl: register the existing authModule; the :app bridge becomes an
  inverse-bridge (Koin owns the single AuthService/TokenProvider instance).
- Inverse-bridges in :app for NetworkApi, ImageLoader, ProxyController,
  NetworkApiRepository (consumed by the still-Hilt data services, domain and
  FlowApplication).
- NetworkModuleTest.verify() validates the network graph in CI.
- Declare kotlinx-coroutines-core explicitly (was transitive via hilt-android).

The data network-backed services stay on Hilt and now resolve NetworkApi/Auth from
Koin via the bridges; they migrate with domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
NetworkModuleTest.verify() statically reflects ProxyControllerImpl's constructor and
flagged its () -> OkHttpClient (Function0) param as a missing definition. That
parameter is supplied directly in the module definition to break the
OkHttpClient <-> ProxyController cycle, so declare it via injectedParameters.

Verified locally: testDebugUnitTest + :app:lintDebug + :app:assembleDebug all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Complete the big-bang DI migration from Hilt to Koin in preparation for
Kotlin Multiplatform. Hilt is now fully removed from the codebase.

- buildSrc: delete AndroidHiltConventionPlugin; AndroidFeatureConventionPlugin
  now wires koin-android instead of Hilt
- core:data / core:domain: replace generated Hilt modules with hand-written
  Koin modules (singleOf/factoryOf with interface binds), de-Hilt all 57 use
  cases and the 7 data services; add koin verify() tests
- core:work:impl: drop @HiltWorker/DelegatingWorker/HiltWorkerFactory entry
  point, schedule workers directly, provide them via workerOf in a Koin module
- core:navigation: viewModel() now resolves through koinViewModel
- app: FlowApplication starts Koin with all modules and uses KoinWorkerFactory
  as the WorkManager factory (default WorkManagerInitializer removed from the
  manifest); MainActivity/TvActivity drop @androidentrypoint; delete the nine
  app-level Hilt bridge modules
- features: de-Hilt all ViewModels (assisted VMs converted to plain
  constructors) and add a per-feature Koin module with viewModelOf

Validated locally: :app:lintDebug, testDebugUnitTest and :app:assembleDebug
all pass; :app:assembleRelease (R8) also succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
Add an app-level test that statically verifies the complete dependency
graph — the same modules FlowApplication assembles at startup — by merging
them via includes() and calling Koin's verify(). This catches missing
bindings that only surface across module boundaries, which the existing
per-module verify tests cannot see.

Wire it as a dedicated "Verify Koin DI graph" CI step so the graph is
checked as a fast, clearly-named gate (the full testDebugUnitTest run also
executes it along with the per-module verify tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
@andrikeev
andrikeev marked this pull request as ready for review June 22, 2026 15:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ce8002c10

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +30 to +31
workerOf(::SyncBookmarksWorker)
workerOf(::SyncFavoritesWorker)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve existing delegated WorkManager jobs

When upgrading users who already enabled favorites/bookmarks sync, their WorkManager database still contains periodic specs whose worker class is flow.work.workers.DelegatingWorker because the old scheduler enqueued DelegatingWorker for SyncFavoritesWork/SyncBookmarksWork. This module now only registers the new concrete workers and the commit deletes DelegatingWorker, and there is no startup path that re-enqueues those unique periodic jobs, so WorkManager cannot instantiate the persisted specs and automatic sync stays broken until the user manually changes the setting. Keep a compatibility worker/registration for one release or cancel/recreate the old unique work during migration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed: the only schedulers are the two Set*SyncPeriodUseCase (menu actions), with no startup re-enqueue path, so persisted periodic specs pointing at the deleted DelegatingWorker would break auto-sync after update.

Fixed in 6ed6f87 by keeping DelegatingWorker as a thin compatibility shim (same FQN + persisted WorkerDelegateClassName key) that now resolves the delegate via KoinWorkerFactory instead of Hilt, registered in workModule. The current scheduler enqueues concrete workers directly, so nothing new is delegated through it — marked to remove one release after the migration ships.


Generated by Claude Code

Older builds scheduled the unique periodic jobs SyncFavoritesWork /
SyncBookmarksWork through DelegatingWorker, storing the real worker class
name in the input data. Those specs are still persisted in the WorkManager
database after an update, so deleting the class left WorkManager unable to
instantiate them — automatic favorites/bookmarks sync silently broke until
the user re-picked the sync period.

Re-introduce DelegatingWorker (same FQN and persisted data key) as a thin
compatibility shim that resolves the delegate via KoinWorkerFactory instead
of Hilt, and register it in workModule so persisted specs remain runnable.
The current scheduler enqueues concrete workers directly, so nothing new is
delegated through it; safe to remove one release after the migration ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJeGoThgxNDUkMDUpUmqho
@andrikeev andrikeev closed this Jun 23, 2026
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.

2 participants